1
0
forked from baron/baron-sso

feat(user): support fixed UUID registration and enhance bulk import results

- Added support for fixed UUIDs during bulk registration (Search-first + ExternalID mapping)
- Implemented idempotency and visibility restoration for soft-deleted users
- Enhanced bulk upload UI to show 'New/Updated/Unchanged' status and modified fields
- Added logic to reclaim identifiers (login_id) from colliding records
- Added frontend E2E and backend unit tests for UUID integrity and conflict handling
- Fixed i18n, formatting, and mock tests to satisfy code-check
- Applied 'go fix' for 'omitzero' tags and general Go standards
This commit is contained in:
2026-06-01 15:34:08 +09:00
parent 4a1e89e421
commit 31d107ff2e
85 changed files with 2104 additions and 1149 deletions

View File

@@ -127,9 +127,9 @@ function hanmacEmailStatusClass(preview?: HanmacImportEmailPreview) {
export const downloadUserTemplate = () => { export const downloadUserTemplate = () => {
const headers = const headers =
"email,sub_email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1"; "uuid,email,sub_email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1";
const example = const example =
"user1@example.com,sub1@test.com;sub2@test.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001,second-tenant,센터,책임,,Architecture,EMP002"; ",user1@example.com,sub1@test.com;sub2@test.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001,second-tenant,센터,책임,,Architecture,EMP002";
const blob = new Blob([`${headers}\n${example}`], { const blob = new Blob([`${headers}\n${example}`], {
type: "text/csv;charset=utf-8;", type: "text/csv;charset=utf-8;",
}); });
@@ -295,22 +295,6 @@ export function UserBulkUploadModal({
}); });
}; };
const downloadTemplate = () => {
const headers =
"email,sub_email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1";
const example =
"user1@example.com,sub1@test.com;sub2@test.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001,second-tenant,센터,책임,,Architecture,EMP002";
const blob = new Blob([`${headers}\n${example}`], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "user_bulk_template.csv";
a.click();
URL.revokeObjectURL(url);
};
const reset = () => { const reset = () => {
setFile(null); setFile(null);
setPreviewData([]); setPreviewData([]);
@@ -410,7 +394,7 @@ export function UserBulkUploadModal({
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={downloadTemplate} onClick={downloadUserTemplate}
className="gap-2" className="gap-2"
> >
<Download size={14} /> <Download size={14} />
@@ -605,15 +589,71 @@ export function UserBulkUploadModal({
) : ( ) : (
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div className="flex items-center gap-4 p-4 rounded-lg bg-muted/30 border"> <div className="flex items-center gap-4 p-4 rounded-lg bg-muted/30 border">
<div className="flex-1 text-center"> {results.some((r) => r.success && r.status === "created") && (
<div className="text-2xl font-bold text-green-600"> <>
{successCount} <div className="flex-1 text-center">
</div> <div className="text-2xl font-bold text-green-600">
<div className="text-xs text-muted-foreground uppercase"> {
{t("ui.common.success", "성공")} results.filter(
</div> (r) => r.success && r.status === "created",
</div> ).length
<div className="w-px h-10 bg-border" /> }
</div>
<div className="text-xs text-muted-foreground uppercase">
{t("ui.common.status.new", "신규")}
</div>
</div>
<div className="w-px h-10 bg-border" />
</>
)}
{results.some((r) => r.success && r.status === "updated") && (
<>
<div className="flex-1 text-center">
<div className="text-2xl font-bold text-blue-600">
{
results.filter(
(r) => r.success && r.status === "updated",
).length
}
</div>
<div className="text-xs text-muted-foreground uppercase">
{t("ui.common.status.updated", "수정")}
</div>
</div>
<div className="w-px h-10 bg-border" />
</>
)}
{results.some((r) => r.success && r.status === "unchanged") && (
<>
<div className="flex-1 text-center">
<div className="text-2xl font-bold text-slate-500">
{
results.filter(
(r) => r.success && r.status === "unchanged",
).length
}
</div>
<div className="text-xs text-muted-foreground uppercase">
{t("ui.common.status.unchanged", "동일")}
</div>
</div>
<div className="w-px h-10 bg-border" />
</>
)}
{!results.some((r) => r.success && r.status) &&
successCount > 0 && (
<>
<div className="flex-1 text-center">
<div className="text-2xl font-bold text-green-600">
{successCount}
</div>
<div className="text-xs text-muted-foreground uppercase">
{t("ui.common.success", "성공")}
</div>
</div>
<div className="w-px h-10 bg-border" />
</>
)}
<div className="flex-1 text-center"> <div className="flex-1 text-center">
<div className="text-2xl font-bold text-destructive"> <div className="text-2xl font-bold text-destructive">
{failCount} {failCount}
@@ -643,7 +683,60 @@ export function UserBulkUploadModal({
/> />
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium truncate">{r.email}</div> <div className="flex items-center gap-2">
<div className="font-medium truncate">{r.email}</div>
{r.success && r.status === "created" && (
<span className="px-1.5 py-0.5 rounded-full bg-green-100 text-green-700 text-[10px] font-bold">
{t("ui.common.status.new", "신규")}
</span>
)}
{r.success && r.status === "updated" && (
<span className="px-1.5 py-0.5 rounded-full bg-blue-100 text-blue-700 text-[10px] font-bold">
{t("ui.common.status.updated", "수정")}
</span>
)}
{r.success && r.status === "unchanged" && (
<span className="px-1.5 py-0.5 rounded-full bg-slate-100 text-slate-600 text-[10px] font-bold">
{t("ui.common.status.unchanged", "동일")}
</span>
)}
{r.success && !r.status && (
<span className="px-1.5 py-0.5 rounded-full bg-green-100 text-green-700 text-[10px] font-bold">
{t("ui.common.success", "성공")}
</span>
)}
</div>
{r.success && r.status === "updated" && (
<div className="mt-1 text-[10px] text-muted-foreground flex flex-wrap gap-1 items-center">
<span className="font-medium">
{t(
"ui.admin.users.bulk.modified_fields",
"수정 항목:",
)}
</span>
{r.modifiedFields &&
r.modifiedFields.length > 0 &&
r.modifiedFields.map((field) => (
<span
key={field}
className="px-1 py-0.5 rounded bg-blue-50 text-blue-600 border border-blue-100"
>
{t(
`ui.admin.users.field.${field.toLowerCase()}`,
field,
)}
</span>
))}
</div>
)}
{r.success && r.status === "unchanged" && (
<div className="mt-1 text-[10px] text-muted-foreground italic">
{t(
"ui.admin.users.bulk.no_changes",
"기존 데이터와 동일 (변경 사항 없음)",
)}
</div>
)}
{!r.success && ( {!r.success && (
<div className="text-xs text-destructive"> <div className="text-xs text-destructive">
{r.message} {r.message}

View File

@@ -28,7 +28,10 @@ export function parseUserCSV(text: string): BulkUserItem[] {
const value = values[index]; const value = values[index];
if (value === undefined || value === "") continue; if (value === undefined || value === "") continue;
if (header === "email") { if (header === "uuid") {
item.id = value;
item.uuid = value;
} else if (header === "email") {
item.email = value; item.email = value;
} else if (header === "name") { } else if (header === "name") {
item.name = value; item.name = value;
@@ -115,8 +118,18 @@ export function parseUserCSV(text: string): BulkUserItem[] {
} else if (header === "firstname") { } else if (header === "firstname") {
item.metadata.naverworks_first_name = value; item.metadata.naverworks_first_name = value;
} else if (header === "id") { } else if (header === "id") {
item.loginId = value; // If it looks like a UUID, use it as item.id
item.metadata.naverworks_id = value; if (
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
value,
)
) {
item.id = value;
item.uuid = value;
} else {
item.loginId = value;
item.metadata.naverworks_id = value;
}
} else if (header === "personalemail") { } else if (header === "personalemail") {
item.metadata.personal_email = value; item.metadata.personal_email = value;
} else if (header === "subemail") { } else if (header === "subemail") {

View File

@@ -725,6 +725,8 @@ export type BulkUserAppointment = {
}; };
export type BulkUserItem = { export type BulkUserItem = {
id?: string;
uuid?: string;
email: string; email: string;
loginId?: string; loginId?: string;
name: string; name: string;
@@ -761,6 +763,7 @@ export type BulkUserResult = {
success: boolean; success: boolean;
message?: string; message?: string;
userId?: string; userId?: string;
modifiedFields?: string[];
}; };
export type BulkUserResponse = { export type BulkUserResponse = {

View File

@@ -112,7 +112,7 @@ test.describe("Users Bulk Upload Secondary Emails", () => {
await page.getByTestId("bulk-start-btn").click(); await page.getByTestId("bulk-start-btn").click();
await expect(page.getByText(/성공|Success/i)).toBeVisible(); await expect(page.getByText(/성공|Success/i).first()).toBeVisible();
expect(bulkPayload).not.toBeNull(); expect(bulkPayload).not.toBeNull();
expect(bulkPayload.users).toHaveLength(1); expect(bulkPayload.users).toHaveLength(1);

View File

@@ -0,0 +1,178 @@
import { expect, test } from "@playwright/test";
test.describe("Users Bulk Upload UUID Support", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("locale", "ko");
window.localStorage.setItem("admin_session", "fake-token");
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
const authority = "http://localhost:5000/oidc";
const client_id = "adminfront";
const key = `oidc.user:${authority}:${client_id}`;
const authData = {
access_token: "fake-token",
token_type: "Bearer",
profile: { sub: "admin-user", name: "Admin", role: "super_admin" },
expires_at: Math.floor(Date.now() / 1000) + 36000,
};
window.localStorage.setItem(key, JSON.stringify(authData));
});
await page.route("**/api/v1/**", async (route) => {
const url = route.request().url();
const headers = { "Access-Control-Allow-Origin": "*" };
if (url.includes("/user/me")) {
return route.fulfill({
json: {
id: "admin-user",
name: "Admin",
role: "super_admin",
manageableTenants: [],
},
headers,
});
}
if (url.includes("/admin/users")) {
if (!url.includes("/bulk")) {
return route.fulfill({
json: { items: [], total: 0, limit: 50, offset: 0 },
headers,
});
}
}
if (url.includes("/admin/tenants")) {
return route.fulfill({
json: { items: [], total: 0, limit: 100, offset: 0 },
headers,
});
}
return route.fulfill({ json: { items: [], total: 0 }, headers });
});
await page.route("**/oidc/**", async (route) => {
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
});
});
test("should include UUID in bulk upload payload when provided in CSV", async ({
page,
}) => {
let bulkPayload = "";
const testUuid = "550e8400-e29b-41d4-a716-446655440000";
await page.route("**/api/v1/admin/users/bulk", async (route) => {
bulkPayload = route.request().postData() ?? "";
return route.fulfill({
json: {
results: [
{ email: "uuid@test.com", success: true, userId: testUuid },
],
},
headers: { "Access-Control-Allow-Origin": "*" },
});
});
await page.goto("/users");
await page.getByTestId("user-data-mgmt-btn").click();
await page
.getByRole("menuitem", { name: /일괄 임포트|Bulk Import/i })
.click();
await page.locator('input[type="file"]').setInputFiles({
name: "users_uuid.csv",
mimeType: "text/csv",
buffer: Buffer.from(
`email,name,uuid\nuuid@test.com,UUID User,${testUuid}\n`,
),
});
await page.getByTestId("bulk-start-btn").click();
await expect(page.getByText("uuid@test.com")).toBeVisible();
const payload = JSON.parse(bulkPayload);
expect(payload.users[0].uuid).toBe(testUuid);
expect(payload.users[0].id).toBe(testUuid);
});
test("should support 'id' column as UUID if format matches", async ({
page,
}) => {
let bulkPayload = "";
const testUuid = "550e8400-e29b-41d4-a716-446655440001";
await page.route("**/api/v1/admin/users/bulk", async (route) => {
bulkPayload = route.request().postData() ?? "";
return route.fulfill({
json: {
results: [
{ email: "id-uuid@test.com", success: true, userId: testUuid },
],
},
headers: { "Access-Control-Allow-Origin": "*" },
});
});
await page.goto("/users");
await page.getByTestId("user-data-mgmt-btn").click();
await page
.getByRole("menuitem", { name: /일괄 임포트|Bulk Import/i })
.click();
await page.locator('input[type="file"]').setInputFiles({
name: "users_id.csv",
mimeType: "text/csv",
buffer: Buffer.from(
`email,name,id\nid-uuid@test.com,ID UUID User,${testUuid}\n`,
),
});
await page.getByTestId("bulk-start-btn").click();
const payload = JSON.parse(bulkPayload);
expect(payload.users[0].uuid).toBe(testUuid);
expect(payload.users[0].id).toBe(testUuid);
});
test("should show conflict error message when UUID already exists", async ({
page,
}) => {
const testUuid = "550e8400-e29b-41d4-a716-446655440002";
const conflictMsg = `Conflict: UUID already exists (${testUuid})`;
await page.route("**/api/v1/admin/users/bulk", async (route) => {
return route.fulfill({
json: {
results: [
{
email: "conflict@test.com",
success: false,
message: conflictMsg,
},
],
},
headers: { "Access-Control-Allow-Origin": "*" },
});
});
await page.goto("/users");
await page.getByTestId("user-data-mgmt-btn").click();
await page
.getByRole("menuitem", { name: /일괄 임포트|Bulk Import/i })
.click();
await page.locator('input[type="file"]').setInputFiles({
name: "users_conflict.csv",
mimeType: "text/csv",
buffer: Buffer.from(
`email,name,uuid\nconflict@test.com,Conflict User,${testUuid}\n`,
),
});
await page.getByTestId("bulk-start-btn").click();
await expect(page.getByText(conflictMsg)).toBeVisible();
});
});

View File

@@ -110,7 +110,7 @@ func (m *e2eMockKratosAdminService) ListIdentities(ctx context.Context) ([]servi
return nil, nil return nil, nil
} }
func (m *e2eMockKratosAdminService) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*service.KratosIdentity, error) { func (m *e2eMockKratosAdminService) UpdateIdentity(ctx context.Context, identityID string, traits map[string]any, state string) (*service.KratosIdentity, error) {
return nil, nil return nil, nil
} }
@@ -325,7 +325,7 @@ func runHeadlessPasswordLoginE2ERequest(
Client: domain.HydraClient{ Client: domain.HydraClient{
ClientID: "headless-login-client", ClientID: "headless-login-client",
TokenEndpointAuthMethod: "none", TokenEndpointAuthMethod: "none",
Metadata: map[string]interface{}{ Metadata: map[string]any{
"status": "active", "status": "active",
"headless_login_enabled": true, "headless_login_enabled": true,
"headless_token_endpoint_auth_method": "private_key_jwt", "headless_token_endpoint_auth_method": "private_key_jwt",

View File

@@ -459,7 +459,7 @@ func main() {
app.Use(cors.New(cors.Config{ app.Use(cors.New(cors.Config{
AllowOriginsFunc: func(origin string) bool { AllowOriginsFunc: func(origin string) bool {
// 1. Check static allowed list // 1. Check static allowed list
for _, allowed := range strings.Split(allowedOrigins, ",") { for allowed := range strings.SplitSeq(allowedOrigins, ",") {
if origin == strings.TrimSpace(allowed) { if origin == strings.TrimSpace(allowed) {
return true return true
} }
@@ -857,9 +857,9 @@ func main() {
// Client Logging Route (Standardized & Flattened) // Client Logging Route (Standardized & Flattened)
api.Post("/client-log", func(c *fiber.Ctx) error { api.Post("/client-log", func(c *fiber.Ctx) error {
type LogReq struct { type LogReq struct {
Level string `json:"level"` Level string `json:"level"`
Message string `json:"message"` Message string `json:"message"`
Data map[string]interface{} `json:"data,omitempty"` Data map[string]any `json:"data,omitempty"`
} }
var req LogReq var req LogReq
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {

View File

@@ -136,7 +136,7 @@ func buildSuperAdminBrokerUser(email, name string) *domain.BrokerUser {
Email: email, Email: email,
Name: name, Name: name,
PhoneNumber: "", PhoneNumber: "",
Attributes: map[string]interface{}{ Attributes: map[string]any{
"department": "Admin", "department": "Admin",
"affiliationType": "internal", "affiliationType": "internal",
"grade": "", "grade": "",
@@ -170,7 +170,7 @@ func (s *gormSuperAdminStore) CreateUser(ctx context.Context, user *domain.User)
} }
func (s *gormSuperAdminStore) UpdateUserSuperAdmin(ctx context.Context, userID string, name string) (*domain.User, error) { func (s *gormSuperAdminStore) UpdateUserSuperAdmin(ctx context.Context, userID string, name string) (*domain.User, error) {
updates := map[string]interface{}{ updates := map[string]any{
"role": domain.RoleSuperAdmin, "role": domain.RoleSuperAdmin,
"status": domain.UserStatusActive, "status": domain.UserStatusActive,
"updated_at": time.Now(), "updated_at": time.Now(),

View File

@@ -31,7 +31,7 @@ func SeedAdminIdentity(idp domain.IdentityProvider) (string, error) {
Email: adminEmail, Email: adminEmail,
Name: adminName, Name: adminName,
PhoneNumber: "", PhoneNumber: "",
Attributes: map[string]interface{}{ Attributes: map[string]any{
"department": "Admin", "department": "Admin",
"affiliationType": "internal", "affiliationType": "internal",
"grade": "", "grade": "",
@@ -44,7 +44,7 @@ func SeedAdminIdentity(idp domain.IdentityProvider) (string, error) {
var err error var err error
var identityID string var identityID string
for i := 0; i < maxRetries; i++ { for i := range maxRetries {
identityID, err = idp.CreateUser(user, adminPassword) identityID, err = idp.CreateUser(user, adminPassword)
if err == nil { if err == nil {
slog.Info("[Bootstrap] Admin identity created in IDP", "email", adminEmail, "idp", idp.Name(), "id", identityID) slog.Info("[Bootstrap] Admin identity created in IDP", "email", adminEmail, "idp", idp.Name(), "id", identityID)

View File

@@ -54,7 +54,7 @@ func SyncAdminRole(db *gorm.DB, kratosID string) error {
} }
// Update role if needed // Update role if needed
updates := map[string]interface{}{} updates := map[string]any{}
if user.Role != domain.RoleSuperAdmin { if user.Role != domain.RoleSuperAdmin {
updates["role"] = domain.RoleSuperAdmin updates["role"] = domain.RoleSuperAdmin
} }

View File

@@ -98,15 +98,15 @@ var hanmacInitialRomanization = []string{
func SplitEmailDomain(email string) (string, string, error) { func SplitEmailDomain(email string) (string, string, error) {
normalized := strings.ToLower(strings.TrimSpace(email)) normalized := strings.ToLower(strings.TrimSpace(email))
at := strings.Index(normalized, "@") before, after, ok := strings.Cut(normalized, "@")
if at < 0 { if !ok {
return "", "", errors.New("email must contain @") return "", "", errors.New("email must contain @")
} }
if strings.Count(normalized, "@") != 1 { if strings.Count(normalized, "@") != 1 {
return "", "", errors.New("email must contain one @") return "", "", errors.New("email must contain one @")
} }
localPart := strings.TrimSpace(normalized[:at]) localPart := strings.TrimSpace(before)
domainPart := strings.TrimSpace(normalized[at+1:]) domainPart := strings.TrimSpace(after)
if domainPart == "" || !strings.Contains(domainPart, ".") { if domainPart == "" || !strings.Contains(domainPart, ".") {
return "", "", errors.New("email domain is invalid") return "", "", errors.New("email domain is invalid")
} }

View File

@@ -19,21 +19,21 @@ const (
) )
type HydraClient struct { type HydraClient struct {
ClientID string `json:"client_id"` ClientID string `json:"client_id"`
ClientName string `json:"client_name,omitempty"` ClientName string `json:"client_name,omitempty"`
ClientSecret string `json:"client_secret,omitempty"` // Added ClientSecret string `json:"client_secret,omitempty"` // Added
ClientURI string `json:"client_uri,omitempty"` ClientURI string `json:"client_uri,omitempty"`
RedirectURIs []string `json:"redirect_uris,omitempty"` RedirectURIs []string `json:"redirect_uris,omitempty"`
GrantTypes []string `json:"grant_types,omitempty"` GrantTypes []string `json:"grant_types,omitempty"`
ResponseTypes []string `json:"response_types,omitempty"` ResponseTypes []string `json:"response_types,omitempty"`
Scope string `json:"scope,omitempty"` Scope string `json:"scope,omitempty"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
SkipConsent *bool `json:"skip_consent,omitempty"` SkipConsent *bool `json:"skip_consent,omitempty"`
JWKSUri string `json:"jwks_uri,omitempty"` JWKSUri string `json:"jwks_uri,omitempty"`
JWKS interface{} `json:"jwks,omitempty"` JWKS any `json:"jwks,omitempty"`
BackChannelLogoutURI string `json:"backchannel_logout_uri,omitempty"` BackChannelLogoutURI string `json:"backchannel_logout_uri,omitempty"`
BackChannelLogoutSessionRequired *bool `json:"backchannel_logout_session_required,omitempty"` BackChannelLogoutSessionRequired *bool `json:"backchannel_logout_session_required,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"` Metadata map[string]any `json:"metadata,omitempty"`
} }
func (c *HydraClient) SupportsHeadlessLogin() bool { func (c *HydraClient) SupportsHeadlessLogin() bool {
@@ -65,7 +65,7 @@ func (c *HydraClient) HeadlessJWKSURI() string {
return strings.TrimSpace(c.JWKSUri) return strings.TrimSpace(c.JWKSUri)
} }
func (c *HydraClient) HeadlessJWKS() interface{} { func (c *HydraClient) HeadlessJWKS() any {
if c.Metadata != nil { if c.Metadata != nil {
if value, ok := c.Metadata[MetadataHeadlessJWKS]; ok && value != nil { if value, ok := c.Metadata[MetadataHeadlessJWKS]; ok && value != nil {
return value return value
@@ -140,6 +140,6 @@ type HydraConsentSession struct {
AuthenticatedAt *time.Time `json:"authenticated_at,omitempty"` AuthenticatedAt *time.Time `json:"authenticated_at,omitempty"`
RequestedAt *time.Time `json:"requested_at,omitempty"` RequestedAt *time.Time `json:"requested_at,omitempty"`
HandledAt *time.Time `json:"handled_at,omitempty"` HandledAt *time.Time `json:"handled_at,omitempty"`
Client HydraClient `json:"client,omitempty"` Client HydraClient `json:"client"`
ConsentRequest *HydraConsentRequest `json:"consent_request,omitempty"` ConsentRequest *HydraConsentRequest `json:"consent_request,omitempty"`
} }

View File

@@ -20,7 +20,7 @@ type BrokerUser struct {
PhoneNumber string `json:"phone_number"` PhoneNumber string `json:"phone_number"`
// Attributes stores custom user attributes. // Attributes stores custom user attributes.
// The "required_keys" tag specifies which keys MUST be present in the IDP's schema support. // The "required_keys" tag specifies which keys MUST be present in the IDP's schema support.
Attributes map[string]interface{} `json:"attributes" required_keys:"grade,department"` Attributes map[string]any `json:"attributes" required_keys:"grade,department"`
} }
// IDPMetadata represents the schema capabilities of an Identity Provider. // IDPMetadata represents the schema capabilities of an Identity Provider.

View File

@@ -2,6 +2,7 @@ package domain
import ( import (
"fmt" "fmt"
"slices"
"strings" "strings"
"time" "time"
@@ -208,10 +209,8 @@ func ValidateLoginID(loginID string, emails []string, phone string) error {
reserved := []string{"admin", "system", "root", "master", "superuser", "guest", "operator"} reserved := []string{"admin", "system", "root", "master", "superuser", "guest", "operator"}
lowerID := strings.ToLower(loginID) lowerID := strings.ToLower(loginID)
for _, r := range reserved { if slices.Contains(reserved, lowerID) {
if lowerID == r { return fmt.Errorf("reserved ID cannot be used")
return fmt.Errorf("reserved ID cannot be used")
}
} }
return nil return nil

View File

@@ -26,7 +26,7 @@ func TestApiKeyHandler_CreateApiKey(t *testing.T) {
app.Post("/api-keys", h.CreateApiKey) app.Post("/api-keys", h.CreateApiKey)
input := map[string]interface{}{ input := map[string]any{
"name": "M2M Test", "name": "M2M Test",
"scopes": []string{"read", "write"}, "scopes": []string{"read", "write"},
} }
@@ -47,7 +47,7 @@ func TestApiKeyHandler_Validation(t *testing.T) {
app.Post("/api-keys", h.CreateApiKey) app.Post("/api-keys", h.CreateApiKey)
// Missing name // Missing name
input := map[string]interface{}{ input := map[string]any{
"scopes": []string{"read"}, "scopes": []string{"read"},
} }
body, _ := json.Marshal(input) body, _ := json.Marshal(input)
@@ -65,7 +65,7 @@ func TestApiKeyHandler_UpdateApiKeyScopesRequiresDatabase(t *testing.T) {
app.Patch("/api-keys/:id", h.UpdateApiKey) app.Patch("/api-keys/:id", h.UpdateApiKey)
body, _ := json.Marshal(map[string]interface{}{ body, _ := json.Marshal(map[string]any{
"scopes": []string{"org-context:read"}, "scopes": []string{"org-context:read"},
}) })
req := httptest.NewRequest("PATCH", "/api-keys/api-key-id", bytes.NewReader(body)) req := httptest.NewRequest("PATCH", "/api-keys/api-key-id", bytes.NewReader(body))

View File

@@ -16,11 +16,13 @@ import (
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"maps"
"math/rand" "math/rand"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"slices"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -601,7 +603,7 @@ func (h *AuthHandler) GetActiveTenants(c *fiber.Ctx) error {
email := c.Query("email") email := c.Query("email")
if email == "" { if email == "" {
// No email provided, return empty list (Security policy) // No email provided, return empty list (Security policy)
return c.JSON([]interface{}{}) return c.JSON([]any{})
} }
// 1. Verify Verification Status in Redis // 1. Verify Verification Status in Redis
@@ -615,7 +617,7 @@ func (h *AuthHandler) GetActiveTenants(c *fiber.Ctx) error {
// 2. Extract domain from verified email // 2. Extract domain from verified email
parts := strings.Split(email, "@") parts := strings.Split(email, "@")
if len(parts) != 2 { if len(parts) != 2 {
return c.JSON([]interface{}{}) return c.JSON([]any{})
} }
domainName := parts[1] domainName := parts[1]
@@ -623,7 +625,7 @@ func (h *AuthHandler) GetActiveTenants(c *fiber.Ctx) error {
isInternal, _ := h.isAffiliateTenant(c.Context(), domainName) isInternal, _ := h.isAffiliateTenant(c.Context(), domainName)
if !isInternal { if !isInternal {
// If not an affiliate email, do not show any tenants // If not an affiliate email, do not show any tenants
return c.JSON([]interface{}{}) return c.JSON([]any{})
} }
// 3. List and Filter Tenants // 3. List and Filter Tenants
@@ -785,7 +787,7 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
slog.Info("[Signup] Phone normalization", "raw", req.Phone, "normalized", normalizedPhone) slog.Info("[Signup] Phone normalization", "raw", req.Phone, "normalized", normalizedPhone)
// IDP에 전달할 BrokerUser 스키마 구성 // IDP에 전달할 BrokerUser 스키마 구성
attributes := map[string]interface{}{ attributes := map[string]any{
"department": req.Department, "department": req.Department,
"affiliationType": req.AffiliationType, "affiliationType": req.AffiliationType,
"grade": "", "grade": "",
@@ -854,9 +856,7 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
// Merge metadata // Merge metadata
localUser.Metadata = make(domain.JSONMap) localUser.Metadata = make(domain.JSONMap)
for k, v := range req.Metadata { maps.Copy(localUser.Metadata, req.Metadata)
localUser.Metadata[k] = v
}
if h.UserRepo != nil { if h.UserRepo != nil {
go func(u *domain.User, ids []domain.UserLoginID) { go func(u *domain.User, ids []domain.UserLoginID) {
@@ -915,7 +915,7 @@ func (h *AuthHandler) getBearerToken(c *fiber.Ctx) string {
} }
func firstForwardedValue(raw string) string { func firstForwardedValue(raw string) string {
for _, part := range strings.Split(raw, ",") { for part := range strings.SplitSeq(raw, ",") {
value := strings.TrimSpace(part) value := strings.TrimSpace(part)
if value != "" { if value != "" {
return value return value
@@ -925,8 +925,8 @@ func firstForwardedValue(raw string) string {
} }
func forwardedDirective(raw, key string) string { func forwardedDirective(raw, key string) string {
for _, group := range strings.Split(raw, ",") { for group := range strings.SplitSeq(raw, ",") {
for _, directive := range strings.Split(group, ";") { for directive := range strings.SplitSeq(group, ";") {
pair := strings.SplitN(strings.TrimSpace(directive), "=", 2) pair := strings.SplitN(strings.TrimSpace(directive), "=", 2)
if len(pair) != 2 { if len(pair) != 2 {
continue continue
@@ -1075,9 +1075,9 @@ func (h *AuthHandler) GetTenantInfo(c *fiber.Ctx) error {
if loginIdField, ok := tenant.Config["loginIdField"].(string); ok && loginIdField != "" { if loginIdField, ok := tenant.Config["loginIdField"].(string); ok && loginIdField != "" {
res["loginIdField"] = loginIdField res["loginIdField"] = loginIdField
// Find label in userSchema // Find label in userSchema
if schema, ok := tenant.Config["userSchema"].([]interface{}); ok { if schema, ok := tenant.Config["userSchema"].([]any); ok {
for _, field := range schema { for _, field := range schema {
if f, ok := field.(map[string]interface{}); ok { if f, ok := field.(map[string]any); ok {
if f["key"] == loginIdField { if f["key"] == loginIdField {
res["loginIdLabel"] = f["label"] res["loginIdLabel"] = f["label"]
break break
@@ -1215,9 +1215,7 @@ func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID
if includeTenantDetails { if includeTenantDetails {
// tenant 스코프가 있을 때만 대표소속 namespace metadata를 top-level claim으로 펼칩니다. // tenant 스코프가 있을 때만 대표소속 namespace metadata를 top-level claim으로 펼칩니다.
if namespaced, ok := traits[tenantID].(map[string]any); ok { if namespaced, ok := traits[tenantID].(map[string]any); ok {
for k, v := range namespaced { maps.Copy(claims, namespaced)
claims[k] = v
}
} }
} }
} }
@@ -1570,7 +1568,7 @@ func tenantClaimAncestorSummaries(ancestors []*domain.Tenant) []map[string]any {
return items return items
} }
func applyConfiguredIDTokenClaims(baseClaims map[string]any, metadata map[string]interface{}) map[string]any { func applyConfiguredIDTokenClaims(baseClaims map[string]any, metadata map[string]any) map[string]any {
if baseClaims == nil { if baseClaims == nil {
baseClaims = map[string]any{} baseClaims = map[string]any{}
} }
@@ -1670,7 +1668,7 @@ func (h *AuthHandler) withRPProfileClaims(ctx context.Context, claims map[string
claims["rp_profiles"] = append(existing, profile) claims["rp_profiles"] = append(existing, profile)
return claims return claims
} }
if existing, ok := claims["rp_profiles"].([]interface{}); ok { if existing, ok := claims["rp_profiles"].([]any); ok {
claims["rp_profiles"] = append(existing, profile) claims["rp_profiles"] = append(existing, profile)
return claims return claims
} }
@@ -1678,7 +1676,7 @@ func (h *AuthHandler) withRPProfileClaims(ctx context.Context, claims map[string
return claims return claims
} }
func extractClaimEnabledCustomUserSchemaKeys(metadata map[string]interface{}) []string { func extractClaimEnabledCustomUserSchemaKeys(metadata map[string]any) []string {
if metadata == nil { if metadata == nil {
return nil return nil
} }
@@ -1687,12 +1685,12 @@ func extractClaimEnabledCustomUserSchemaKeys(metadata map[string]interface{}) []
return nil return nil
} }
var items []interface{} var items []any
switch schema := rawSchema.(type) { switch schema := rawSchema.(type) {
case []interface{}: case []any:
items = schema items = schema
case []map[string]interface{}: case []map[string]any:
items = make([]interface{}, 0, len(schema)) items = make([]any, 0, len(schema))
for _, item := range schema { for _, item := range schema {
items = append(items, item) items = append(items, item)
} }
@@ -1703,7 +1701,7 @@ func extractClaimEnabledCustomUserSchemaKeys(metadata map[string]interface{}) []
keys := make([]string, 0, len(items)) keys := make([]string, 0, len(items))
seen := make(map[string]struct{}) seen := make(map[string]struct{})
for _, item := range items { for _, item := range items {
field, ok := item.(map[string]interface{}) field, ok := item.(map[string]any)
if !ok { if !ok {
if typed, typedOK := item.(map[string]any); typedOK { if typed, typedOK := item.(map[string]any); typedOK {
field = typed field = typed
@@ -4275,11 +4273,11 @@ func (h *AuthHandler) ScanQRLogin(c *fiber.Ctx) error {
} }
type kratosCourierRequest struct { type kratosCourierRequest struct {
Recipient string `json:"recipient"` Recipient string `json:"recipient"`
TemplateType string `json:"template_type"` TemplateType string `json:"template_type"`
TemplateData map[string]interface{} `json:"template_data"` TemplateData map[string]any `json:"template_data"`
Subject string `json:"subject"` Subject string `json:"subject"`
Body string `json:"body"` Body string `json:"body"`
} }
// HandleKratosCourierRelay - Kratos courier HTTP 요청을 받아 메일/SMS 발송으로 변환합니다. // HandleKratosCourierRelay - Kratos courier HTTP 요청을 받아 메일/SMS 발송으로 변환합니다.
@@ -4604,7 +4602,7 @@ func (h *AuthHandler) isSmsCodeOnly(loginID string) bool {
func (h *AuthHandler) generateShortCode(code string) string { func (h *AuthHandler) generateShortCode(code string) string {
const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
for i := 0; i < 10; i++ { for range 10 {
b := make([]byte, 2) b := make([]byte, 2)
if _, err := crand.Read(b); err != nil { if _, err := crand.Read(b); err != nil {
break break
@@ -4646,7 +4644,7 @@ func firstNonEmpty(values ...string) string {
return "" return ""
} }
func extractFirstString(data map[string]interface{}, keys ...string) string { func extractFirstString(data map[string]any, keys ...string) string {
if data == nil { if data == nil {
return "" return ""
} }
@@ -5229,10 +5227,7 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
} }
candidates := buildLoginCandidates(profile) candidates := buildLoginCandidates(profile)
fetchLimit := limit * 10 fetchLimit := max(limit*10, limit)
if fetchLimit < limit {
fetchLimit = limit
}
if fetchLimit > 500 { if fetchLimit > 500 {
fetchLimit = 500 fetchLimit = 500
} }
@@ -5805,9 +5800,9 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
} }
for _, log := range auditLogs { for _, log := range auditLogs {
var details struct { var details struct {
ClientID string `json:"client_id"` ClientID string `json:"client_id"`
ClientName string `json:"client_name"` ClientName string `json:"client_name"`
Scopes interface{} `json:"scopes"` Scopes any `json:"scopes"`
} }
// 로그 Details 파싱 // 로그 Details 파싱
if err := json.Unmarshal([]byte(log.Details), &details); err != nil { if err := json.Unmarshal([]byte(log.Details), &details); err != nil {
@@ -5824,7 +5819,7 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
// 스코프 추출 (consent.granted인 경우) // 스코프 추출 (consent.granted인 경우)
scopes := []string{} scopes := []string{}
if sList, ok := details.Scopes.([]interface{}); ok { if sList, ok := details.Scopes.([]any); ok {
for _, s := range sList { for _, s := range sList {
if str, ok := s.(string); ok { if str, ok := s.(string); ok {
scopes = append(scopes, str) scopes = append(scopes, str)
@@ -5927,7 +5922,7 @@ func (h *AuthHandler) RevokeLinkedRp(c *fiber.Ctx) error {
} }
if h.AuditRepo != nil { if h.AuditRepo != nil {
detailsMap := map[string]interface{}{ detailsMap := map[string]any{
"client_id": clientID, "client_id": clientID,
} }
detailsBytes, _ := json.Marshal(detailsMap) detailsBytes, _ := json.Marshal(detailsMap)
@@ -6085,7 +6080,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
} }
if h.AuditRepo != nil { if h.AuditRepo != nil {
detailsMap := map[string]interface{}{ detailsMap := map[string]any{
"client_id": consentRequest.Client.ClientID, "client_id": consentRequest.Client.ClientID,
"scopes": consentRequest.RequestedScope, "scopes": consentRequest.RequestedScope,
"client_name": consentRequest.Client.ClientName, "client_name": consentRequest.Client.ClientName,
@@ -6135,12 +6130,12 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
// structured_scopes 파싱 및 scope_details 생성 // structured_scopes 파싱 및 scope_details 생성
if metadata := consentRequest.Client.Metadata; metadata != nil { if metadata := consentRequest.Client.Metadata; metadata != nil {
if rawScopes, ok := metadata["structured_scopes"]; ok { if rawScopes, ok := metadata["structured_scopes"]; ok {
scopeDetails := make(map[string]map[string]interface{}) scopeDetails := make(map[string]map[string]any)
// JSON 언마샬링 등을 통해 map[string]interface{} 또는 []interface{}로 들어옴 // JSON 언마샬링 등을 통해 map[string]interface{} 또는 []interface{}로 들어옴
// 안전하게 처리 // 안전하게 처리
rawBytes, _ := json.Marshal(rawScopes) rawBytes, _ := json.Marshal(rawScopes)
var scopesList []map[string]interface{} var scopesList []map[string]any
if err := json.Unmarshal(rawBytes, &scopesList); err == nil { if err := json.Unmarshal(rawBytes, &scopesList); err == nil {
for _, item := range scopesList { for _, item := range scopesList {
name, _ := item["name"].(string) name, _ := item["name"].(string)
@@ -6150,7 +6145,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
desc, _ := item["description"].(string) desc, _ := item["description"].(string)
mandatory, _ := item["mandatory"].(bool) mandatory, _ := item["mandatory"].(bool)
scopeDetails[name] = map[string]interface{}{ scopeDetails[name] = map[string]any{
"description": desc, "description": desc,
"mandatory": mandatory, "mandatory": mandatory,
} }
@@ -6280,7 +6275,7 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
} }
if h.AuditRepo != nil { if h.AuditRepo != nil {
detailsMap := map[string]interface{}{ detailsMap := map[string]any{
"client_id": consentRequest.Client.ClientID, "client_id": consentRequest.Client.ClientID,
"scopes": consentRequest.RequestedScope, "scopes": consentRequest.RequestedScope,
"client_name": consentRequest.Client.ClientName, "client_name": consentRequest.Client.ClientName,
@@ -6618,7 +6613,7 @@ func appendLoginIDsFromValues(subjects []string, email string, phone string) []s
return subjects return subjects
} }
func appendLoginIDsFromTraits(subjects []string, traits map[string]interface{}) []string { func appendLoginIDsFromTraits(subjects []string, traits map[string]any) []string {
if traits == nil { if traits == nil {
return subjects return subjects
} }
@@ -7235,7 +7230,7 @@ func (h *AuthHandler) resolveKratosLoginID(token string) (string, error) {
return loginID, nil return loginID, nil
} }
func pickLoginIDFromTraits(traits map[string]interface{}) string { func pickLoginIDFromTraits(traits map[string]any) string {
if traits == nil { if traits == nil {
return "" return ""
} }
@@ -7409,12 +7404,12 @@ func extractLoginIDFromClaims(claims map[string]any) string {
return "" return ""
} }
func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]interface{}, string, error) { func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]any, string, error) {
identityID, traits, _, usedID, err := h.getKratosIdentityWithSession(sessionToken) identityID, traits, _, usedID, err := h.getKratosIdentityWithSession(sessionToken)
return identityID, traits, usedID, err return identityID, traits, usedID, err
} }
func (h *AuthHandler) getKratosIdentityWithSession(sessionToken string) (string, map[string]interface{}, string, string, error) { func (h *AuthHandler) getKratosIdentityWithSession(sessionToken string) (string, map[string]any, string, string, error) {
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/") kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
if kratosURL == "" { if kratosURL == "" {
kratosURL = "http://kratos:4433" kratosURL = "http://kratos:4433"
@@ -7442,8 +7437,8 @@ func (h *AuthHandler) getKratosIdentityWithSession(sessionToken string) (string,
Identifier string `json:"identifier"` Identifier string `json:"identifier"`
} `json:"authentication_methods"` } `json:"authentication_methods"`
Identity struct { Identity struct {
ID string `json:"id"` ID string `json:"id"`
Traits map[string]interface{} `json:"traits"` Traits map[string]any `json:"traits"`
} `json:"identity"` } `json:"identity"`
} }
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
@@ -7501,7 +7496,7 @@ func (h *AuthHandler) issueKratosSession(ctx context.Context, identityID string)
kratosAdminURL = "http://kratos:4434" kratosAdminURL = "http://kratos:4434"
} }
payload := map[string]interface{}{ payload := map[string]any{
"identity_id": identityID, "identity_id": identityID,
} }
body, _ := json.Marshal(payload) body, _ := json.Marshal(payload)
@@ -7534,12 +7529,12 @@ func (h *AuthHandler) issueKratosSession(ctx context.Context, identityID string)
return parsed.SessionToken, nil return parsed.SessionToken, nil
} }
func (h *AuthHandler) getKratosIdentityWithCookie(cookie string) (string, map[string]interface{}, string, error) { func (h *AuthHandler) getKratosIdentityWithCookie(cookie string) (string, map[string]any, string, error) {
identityID, traits, _, usedID, err := h.getKratosIdentityWithCookieAndSession(cookie) identityID, traits, _, usedID, err := h.getKratosIdentityWithCookieAndSession(cookie)
return identityID, traits, usedID, err return identityID, traits, usedID, err
} }
func (h *AuthHandler) getKratosIdentityWithCookieAndSession(cookie string) (string, map[string]interface{}, string, string, error) { func (h *AuthHandler) getKratosIdentityWithCookieAndSession(cookie string) (string, map[string]any, string, string, error) {
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/") kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
if kratosURL == "" { if kratosURL == "" {
kratosURL = "http://kratos:4433" kratosURL = "http://kratos:4433"
@@ -7567,8 +7562,8 @@ func (h *AuthHandler) getKratosIdentityWithCookieAndSession(cookie string) (stri
Identifier string `json:"identifier"` Identifier string `json:"identifier"`
} `json:"authentication_methods"` } `json:"authentication_methods"`
Identity struct { Identity struct {
ID string `json:"id"` ID string `json:"id"`
Traits map[string]interface{} `json:"traits"` Traits map[string]any `json:"traits"`
} `json:"identity"` } `json:"identity"`
} }
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
@@ -7616,13 +7611,13 @@ func (h *AuthHandler) getKratosSessionIDWithCookie(cookie string) (string, error
return result.ID, nil return result.ID, nil
} }
func (h *AuthHandler) updateKratosIdentity(identityID string, traits map[string]interface{}) error { func (h *AuthHandler) updateKratosIdentity(identityID string, traits map[string]any) error {
kratosAdminURL := strings.TrimRight(os.Getenv("KRATOS_ADMIN_URL"), "/") kratosAdminURL := strings.TrimRight(os.Getenv("KRATOS_ADMIN_URL"), "/")
if kratosAdminURL == "" { if kratosAdminURL == "" {
kratosAdminURL = "http://kratos:4434" kratosAdminURL = "http://kratos:4434"
} }
payload := map[string]interface{}{ payload := map[string]any{
"schema_id": "default", "schema_id": "default",
"traits": traits, "traits": traits,
} }
@@ -7681,7 +7676,7 @@ func (h *AuthHandler) getHydraProfile(ctx context.Context, token string) (*domai
return h.mapKratosIdentityToProfile(identity.ID, identity.Traits), nil return h.mapKratosIdentityToProfile(identity.ID, identity.Traits), nil
} }
func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[string]interface{}) *domain.UserProfileResponse { func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[string]any) *domain.UserProfileResponse {
email, _ := traits["email"].(string) email, _ := traits["email"].(string)
name, _ := traits["name"].(string) name, _ := traits["name"].(string)
phone, _ := traits["phone_number"].(string) phone, _ := traits["phone_number"].(string)
@@ -7723,7 +7718,7 @@ func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[s
return profile return profile
} }
func (h *AuthHandler) mapKratosTraitsToLocalUser(identityID string, traits map[string]interface{}, existing *domain.User) *domain.User { func (h *AuthHandler) mapKratosTraitsToLocalUser(identityID string, traits map[string]any, existing *domain.User) *domain.User {
now := time.Now() now := time.Now()
localUser := &domain.User{ localUser := &domain.User{
ID: identityID, ID: identityID,
@@ -7810,7 +7805,7 @@ func (h *AuthHandler) mapKratosTraitsToLocalUser(identityID string, traits map[s
return localUser return localUser
} }
func (h *AuthHandler) syncUpdatedKratosUserReadModel(ctx context.Context, identityID string, traits map[string]interface{}) error { func (h *AuthHandler) syncUpdatedKratosUserReadModel(ctx context.Context, identityID string, traits map[string]any) error {
if h == nil || h.UserRepo == nil { if h == nil || h.UserRepo == nil {
return nil return nil
} }
@@ -7880,7 +7875,7 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
var ( var (
identityID string identityID string
traits map[string]interface{} traits map[string]any
err error err error
) )
if token != "" { if token != "" {
@@ -7929,10 +7924,8 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
if _, isCore := map[string]bool{"email": true, "phone_number": true, "name": true, "department": true, "grade": true, "companyCode": true, "affiliationType": true, "id": true, "role": true, "tenant_id": true}[k]; !isCore { if _, isCore := map[string]bool{"email": true, "phone_number": true, "name": true, "department": true, "grade": true, "companyCode": true, "affiliationType": true, "id": true, "role": true, "tenant_id": true}[k]; !isCore {
// [Fix] Support merging namespaced metadata maps // [Fix] Support merging namespaced metadata maps
if incomingMap, ok := v.(map[string]any); ok { if incomingMap, ok := v.(map[string]any); ok {
if existingMap, ok := traits[k].(map[string]interface{}); ok { if existingMap, ok := traits[k].(map[string]any); ok {
for subK, subV := range incomingMap { maps.Copy(existingMap, incomingMap)
existingMap[subK] = subV
}
traits[k] = existingMap traits[k] = existingMap
} else { } else {
traits[k] = incomingMap traits[k] = incomingMap
@@ -8129,7 +8122,7 @@ func (h *AuthHandler) VerifyUpdateCode(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"success": true}) return c.JSON(fiber.Map{"success": true})
} }
func hydraClientStatus(metadata map[string]interface{}) string { func hydraClientStatus(metadata map[string]any) string {
if metadata == nil { if metadata == nil {
return "active" return "active"
} }
@@ -8142,7 +8135,7 @@ func hydraClientStatus(metadata map[string]interface{}) string {
return "active" return "active"
} }
func extractHydraClientLogo(metadata map[string]interface{}) string { func extractHydraClientLogo(metadata map[string]any) string {
if metadata == nil { if metadata == nil {
return "" return ""
} }
@@ -8198,7 +8191,7 @@ func resolveLinkedRPURL(clientID string, clientURI string, redirectURIs []string
return "" return ""
} }
func resolveLinkedRPAutoLoginSupported(clientID string, metadata map[string]interface{}) bool { func resolveLinkedRPAutoLoginSupported(clientID string, metadata map[string]any) bool {
if readMetadataBoolValue(metadata, domain.MetadataAutoLoginSupported) { if readMetadataBoolValue(metadata, domain.MetadataAutoLoginSupported) {
return true return true
} }
@@ -8210,7 +8203,7 @@ func resolveLinkedRPAutoLoginSupported(clientID string, metadata map[string]inte
} }
} }
func resolveLinkedRPAutoLoginURL(clientID string, metadata map[string]interface{}) string { func resolveLinkedRPAutoLoginURL(clientID string, metadata map[string]any) string {
clientID = strings.TrimSpace(clientID) clientID = strings.TrimSpace(clientID)
if metadataURL := readMetadataStringValue(metadata, domain.MetadataAutoLoginURL); metadataURL != "" { if metadataURL := readMetadataStringValue(metadata, domain.MetadataAutoLoginURL); metadataURL != "" {
if clientID == "orgfront" { if clientID == "orgfront" {
@@ -8253,7 +8246,7 @@ func ensureOrgfrontAutoLoginURL(rawURL string) string {
return parsed.String() return parsed.String()
} }
func resolveLinkedRPInitURL(clientID string, metadata map[string]interface{}) string { func resolveLinkedRPInitURL(clientID string, metadata map[string]any) string {
if !resolveLinkedRPAutoLoginSupported(clientID, metadata) { if !resolveLinkedRPAutoLoginSupported(clientID, metadata) {
return "" return ""
} }
@@ -8548,7 +8541,7 @@ func (h *AuthHandler) ListRpHistory(c *fiber.Ctx) error {
ts := log.Timestamp ts := log.Timestamp
item.LastApprovedAt = &ts item.LastApprovedAt = &ts
if scopesRaw, ok := details["scopes"].([]interface{}); ok { if scopesRaw, ok := details["scopes"].([]any); ok {
scopes := make([]string, 0, len(scopesRaw)) scopes := make([]string, 0, len(scopesRaw))
for _, s := range scopesRaw { for _, s := range scopesRaw {
if str, ok := s.(string); ok { if str, ok := s.(string); ok {
@@ -8811,7 +8804,7 @@ func extractStringLikeValue(raw any) string {
} }
} }
func extractHydraSessionID(ext map[string]interface{}) string { func extractHydraSessionID(ext map[string]any) string {
if len(ext) == 0 { if len(ext) == 0 {
return "" return ""
} }
@@ -8886,13 +8879,7 @@ func (h *AuthHandler) loadSessionClientBindings(ctx context.Context, userID stri
} }
existing := bindings[sessionID] existing := bindings[sessionID]
seen := false seen := slices.Contains(existing, clientID)
for _, candidate := range existing {
if candidate == clientID {
seen = true
break
}
}
if !seen { if !seen {
bindings[sessionID] = append(existing, clientID) bindings[sessionID] = append(existing, clientID)
} }

View File

@@ -16,6 +16,7 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"gorm.io/gorm"
) )
// --- Async Test Mocks --- // --- Async Test Mocks ---
@@ -133,6 +134,10 @@ func (m *AsyncMockUserRepo) CountByCompanyCodes(ctx context.Context, codes []str
return nil, nil return nil, nil
} }
func (m *AsyncMockUserRepo) DB() *gorm.DB {
return nil
}
func (m *AsyncMockUserRepo) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error { func (m *AsyncMockUserRepo) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error {
return nil return nil
} }

View File

@@ -22,8 +22,8 @@ func TestRevokeLinkedRp_Success(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
// 1. Kratos whoami // 1. Kratos whoami
if r.URL.Path == "/sessions/whoami" { if r.URL.Path == "/sessions/whoami" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"identity": map[string]interface{}{"id": "user-123"}, "identity": map[string]any{"id": "user-123"},
}), nil }), nil
} }
// 2. Hydra Revoke // 2. Hydra Revoke
@@ -75,15 +75,15 @@ func TestRevokeLinkedRp_SendsBackchannelLogoutTokenWhenConfigured(t *testing.T)
var receivedBody string var receivedBody string
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/sessions/whoami" { if r.URL.Path == "/sessions/whoami" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"identity": map[string]interface{}{"id": "user-123"}, "identity": map[string]any{"id": "user-123"},
}), nil }), nil
} }
if r.URL.Host == "hydra.test" && r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" { if r.URL.Host == "hydra.test" && r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" {
return httpResponse(r, http.StatusNoContent, ""), nil return httpResponse(r, http.StatusNoContent, ""), nil
} }
if r.URL.Host == "hydra.test" && r.Method == http.MethodGet && r.URL.Path == "/clients/app-1" { if r.URL.Host == "hydra.test" && r.Method == http.MethodGet && r.URL.Path == "/clients/app-1" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "app-1", "client_id": "app-1",
"backchannel_logout_uri": "https://rp.example.com/backchannel-logout", "backchannel_logout_uri": "https://rp.example.com/backchannel-logout",
}), nil }), nil
@@ -158,8 +158,8 @@ func TestListRpHistory_Aggregation(t *testing.T) {
app.Get("/api/v1/user/rp/history", h.ListRpHistory) app.Get("/api/v1/user/rp/history", h.ListRpHistory)
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"identity": map[string]interface{}{"id": "user-123"}, "identity": map[string]any{"id": "user-123"},
}), nil }), nil
}) })
http.DefaultClient = &http.Client{Transport: transport} http.DefaultClient = &http.Client{Transport: transport}

View File

@@ -39,11 +39,11 @@ func (m *MockKratosAdminServiceForConsent) ListIdentities(ctx context.Context) (
return nil, nil return nil, nil
} }
func (m *MockKratosAdminServiceForConsent) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*service.KratosIdentity, error) { func (m *MockKratosAdminServiceForConsent) UpdateIdentity(ctx context.Context, identityID string, traits map[string]any, state string) (*service.KratosIdentity, error) {
return nil, nil return nil, nil
} }
func (m *MockKratosAdminServiceForConsent) CreateIdentity(ctx context.Context, traits map[string]interface{}) (*service.KratosIdentity, error) { func (m *MockKratosAdminServiceForConsent) CreateIdentity(ctx context.Context, traits map[string]any) (*service.KratosIdentity, error) {
return nil, nil return nil, nil
} }
@@ -146,12 +146,12 @@ func TestGetConsentRequest_Normal(t *testing.T) {
// Mock Hydra transport // Mock Hydra transport
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-123" { if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-123" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"challenge": "challenge-123", "challenge": "challenge-123",
"requested_scope": []string{"openid", "profile"}, "requested_scope": []string{"openid", "profile"},
"skip": false, "skip": false,
"subject": "user-123", "subject": "user-123",
"client": map[string]interface{}{ "client": map[string]any{
"client_id": "client-app", "client_id": "client-app",
"client_name": "Test App", "client_name": "Test App",
}, },
@@ -180,7 +180,7 @@ func TestGetConsentRequest_Normal(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
var body map[string]interface{} var body map[string]any
json.NewDecoder(resp.Body).Decode(&body) json.NewDecoder(resp.Body).Decode(&body)
assert.Equal(t, "challenge-123", body["challenge"]) assert.Equal(t, "challenge-123", body["challenge"])
@@ -190,12 +190,12 @@ func TestGetConsentRequest_Normal(t *testing.T) {
func TestGetConsentRequest_AddsMandatoryTenantScope(t *testing.T) { func TestGetConsentRequest_AddsMandatoryTenantScope(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-tenant-scope" { if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-tenant-scope" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"challenge": "challenge-tenant-scope", "challenge": "challenge-tenant-scope",
"requested_scope": []string{"openid", "profile"}, "requested_scope": []string{"openid", "profile"},
"skip": false, "skip": false,
"subject": "user-123", "subject": "user-123",
"client": map[string]interface{}{ "client": map[string]any{
"client_id": "client-app", "client_id": "client-app",
"client_name": "Test App", "client_name": "Test App",
"metadata": map[string]any{ "metadata": map[string]any{
@@ -224,7 +224,7 @@ func TestGetConsentRequest_AddsMandatoryTenantScope(t *testing.T) {
// Mock profile resolution to allow tenant access // Mock profile resolution to allow tenant access
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{ mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
ID: "user-123", ID: "user-123",
Traits: map[string]interface{}{ Traits: map[string]any{
"email": "user@example.com", "email": "user@example.com",
}, },
}, nil) }, nil)
@@ -259,12 +259,12 @@ func TestGetConsentRequest_AddsMandatoryTenantScope(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
var body map[string]interface{} var body map[string]any
json.NewDecoder(resp.Body).Decode(&body) json.NewDecoder(resp.Body).Decode(&body)
assert.Equal(t, []interface{}{"openid", "tenant", "profile"}, body["requested_scope"]) assert.Equal(t, []any{"openid", "tenant", "profile"}, body["requested_scope"])
scopeDetails := body["scope_details"].(map[string]interface{}) scopeDetails := body["scope_details"].(map[string]any)
tenantDetail := scopeDetails["tenant"].(map[string]interface{}) tenantDetail := scopeDetails["tenant"].(map[string]any)
assert.Equal(t, true, tenantDetail["mandatory"]) assert.Equal(t, true, tenantDetail["mandatory"])
} }
@@ -272,28 +272,28 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
// Hydra: Get Consent Request // Hydra: Get Consent Request
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-skip" { if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-skip" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"challenge": "challenge-skip", "challenge": "challenge-skip",
"requested_scope": []string{"openid"}, "requested_scope": []string{"openid"},
"skip": true, "skip": true,
"subject": "user-123", "subject": "user-123",
"client": map[string]interface{}{ "client": map[string]any{
"client_id": "client-app", "client_id": "client-app",
}, },
}), nil }), nil
} }
// Kratos: Get Identity // Kratos: Get Identity
if r.URL.Path == "/admin/identities/user-123" { if r.URL.Path == "/admin/identities/user-123" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"id": "user-123", "id": "user-123",
"traits": map[string]interface{}{ "traits": map[string]any{
"email": "user@test.com", "email": "user@test.com",
}, },
}), nil }), nil
} }
// Hydra: Accept Consent Request // Hydra: Accept Consent Request
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-skip" { if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-skip" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"redirect_to": "http://rp/cb", "redirect_to": "http://rp/cb",
}), nil }), nil
} }
@@ -320,7 +320,7 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) {
} }
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{ mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
ID: "user-123", ID: "user-123",
Traits: map[string]interface{}{ Traits: map[string]any{
"email": "user@test.com", "email": "user@test.com",
}, },
}, nil) }, nil)
@@ -332,7 +332,7 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
var body map[string]interface{} var body map[string]any
json.NewDecoder(resp.Body).Decode(&body) json.NewDecoder(resp.Body).Decode(&body)
assert.Equal(t, "http://rp/cb", body["redirectTo"]) assert.Equal(t, "http://rp/cb", body["redirectTo"])
assert.Equal(t, 1, len(rpUsageSink.events)) assert.Equal(t, 1, len(rpUsageSink.events))
@@ -345,26 +345,26 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) {
func TestAcceptConsentRequest_Normal(t *testing.T) { func TestAcceptConsentRequest_Normal(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-accept" { if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-accept" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"challenge": "challenge-accept", "challenge": "challenge-accept",
"requested_scope": []string{"openid", "profile"}, "requested_scope": []string{"openid", "profile"},
"subject": "user-123", "subject": "user-123",
"client": map[string]interface{}{ "client": map[string]any{
"client_id": "client-app", "client_id": "client-app",
"client_name": "Test App", "client_name": "Test App",
}, },
}), nil }), nil
} }
if r.URL.Path == "/admin/identities/user-123" { if r.URL.Path == "/admin/identities/user-123" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"id": "user-123", "id": "user-123",
"traits": map[string]interface{}{ "traits": map[string]any{
"email": "user@test.com", "email": "user@test.com",
}, },
}), nil }), nil
} }
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-accept" { if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-accept" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"redirect_to": "http://rp/cb", "redirect_to": "http://rp/cb",
}), nil }), nil
} }
@@ -393,14 +393,14 @@ func TestAcceptConsentRequest_Normal(t *testing.T) {
} }
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{ mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
ID: "user-123", ID: "user-123",
Traits: map[string]interface{}{ Traits: map[string]any{
"email": "user@test.com", "email": "user@test.com",
}, },
}, nil) }, nil)
app := newConsentTestApp(h) app := newConsentTestApp(h)
body, _ := json.Marshal(map[string]interface{}{ body, _ := json.Marshal(map[string]any{
"consent_challenge": "challenge-accept", "consent_challenge": "challenge-accept",
"grant_scope": []string{"openid"}, "grant_scope": []string{"openid"},
}) })
@@ -419,7 +419,7 @@ func TestAcceptConsentRequest_Normal(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "client-app", auditDetails["client_id"]) assert.Equal(t, "client-app", auditDetails["client_id"])
assert.Equal(t, "Test App", auditDetails["client_name"]) assert.Equal(t, "Test App", auditDetails["client_name"])
assert.Equal(t, []interface{}{"openid"}, auditDetails["scopes"]) assert.Equal(t, []any{"openid"}, auditDetails["scopes"])
assert.Equal(t, 1, len(rpUsageSink.events)) assert.Equal(t, 1, len(rpUsageSink.events))
assert.Equal(t, domain.RPUsageEventTypeAuthorizationGranted, rpUsageSink.events[0].EventType) assert.Equal(t, domain.RPUsageEventTypeAuthorizationGranted, rpUsageSink.events[0].EventType)
assert.Equal(t, "user-123", rpUsageSink.events[0].Subject) assert.Equal(t, "user-123", rpUsageSink.events[0].Subject)
@@ -436,11 +436,11 @@ func TestAcceptConsentRequest_EnforcesMandatoryTenantScope(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-tenant-accept" { if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-tenant-accept" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"challenge": "challenge-tenant-accept", "challenge": "challenge-tenant-accept",
"requested_scope": []string{"openid", "profile"}, "requested_scope": []string{"openid", "profile"},
"subject": "user-123", "subject": "user-123",
"client": map[string]interface{}{ "client": map[string]any{
"client_id": "client-app", "client_id": "client-app",
"metadata": map[string]any{ "metadata": map[string]any{
"tenant_id": "tenant-abc", "tenant_id": "tenant-abc",
@@ -456,9 +456,9 @@ func TestAcceptConsentRequest_EnforcesMandatoryTenantScope(t *testing.T) {
}), nil }), nil
} }
if r.URL.Path == "/admin/identities/user-123" { if r.URL.Path == "/admin/identities/user-123" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"id": "user-123", "id": "user-123",
"traits": map[string]interface{}{ "traits": map[string]any{
"email": "user@test.com", "email": "user@test.com",
}, },
}), nil }), nil
@@ -466,10 +466,10 @@ func TestAcceptConsentRequest_EnforcesMandatoryTenantScope(t *testing.T) {
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-tenant-accept" { if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-tenant-accept" {
var payload map[string]any var payload map[string]any
assert.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) assert.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
for _, scope := range payload["grant_scope"].([]interface{}) { for _, scope := range payload["grant_scope"].([]any) {
capturedGrantScopes = append(capturedGrantScopes, scope.(string)) capturedGrantScopes = append(capturedGrantScopes, scope.(string))
} }
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"redirect_to": "http://rp/cb", "redirect_to": "http://rp/cb",
}), nil }), nil
} }
@@ -492,14 +492,14 @@ func TestAcceptConsentRequest_EnforcesMandatoryTenantScope(t *testing.T) {
} }
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{ mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
ID: "user-123", ID: "user-123",
Traits: map[string]interface{}{ Traits: map[string]any{
"email": "user@test.com", "email": "user@test.com",
}, },
}, nil) }, nil)
app := newConsentTestApp(h) app := newConsentTestApp(h)
body, _ := json.Marshal(map[string]interface{}{ body, _ := json.Marshal(map[string]any{
"consent_challenge": "challenge-tenant-accept", "consent_challenge": "challenge-tenant-accept",
"grant_scope": []string{"openid", "profile"}, "grant_scope": []string{"openid", "profile"},
}) })

View File

@@ -17,15 +17,15 @@ import (
) )
func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) { func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) {
traits := map[string]interface{}{ traits := map[string]any{
"email": "user@baron.com", "email": "user@baron.com",
"name": "홍길동", "name": "홍길동",
"tenant_id": "primary-tenant-999", // Added primary tenant "tenant_id": "primary-tenant-999", // Added primary tenant
"tenant-1": map[string]interface{}{ "tenant-1": map[string]any{
"department": "개발팀", "department": "개발팀",
"grade": "선임", "grade": "선임",
}, },
"tenant-2": map[string]interface{}{ "tenant-2": map[string]any{
"department": "재무팀", "department": "재무팀",
"grade": "팀장", "grade": "팀장",
}, },
@@ -130,18 +130,18 @@ func TestRepresentativeTenantIDFromTraits(t *testing.T) {
} }
func TestAcceptConsentRequest_DynamicClaims(t *testing.T) { func TestAcceptConsentRequest_DynamicClaims(t *testing.T) {
var capturedClaims map[string]interface{} var capturedClaims map[string]any
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
// Hydra: Get Consent Request // Hydra: Get Consent Request
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-dynamic" { if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-dynamic" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"challenge": "challenge-dynamic", "challenge": "challenge-dynamic",
"requested_scope": []string{"openid", "profile", "tenant"}, "requested_scope": []string{"openid", "profile", "tenant"},
"subject": "user-123", "subject": "user-123",
"client": map[string]interface{}{ "client": map[string]any{
"client_id": "client-app", "client_id": "client-app",
"metadata": map[string]interface{}{ "metadata": map[string]any{
"tenant_id": "tenant-abc", "tenant_id": "tenant-abc",
}, },
}, },
@@ -149,12 +149,12 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) {
} }
// Kratos: Get Identity // Kratos: Get Identity
if r.URL.Path == "/admin/identities/user-123" { if r.URL.Path == "/admin/identities/user-123" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"id": "user-123", "id": "user-123",
"traits": map[string]interface{}{ "traits": map[string]any{
"email": "user@test.com", "email": "user@test.com",
"name": "Test User", "name": "Test User",
"tenant-abc": map[string]interface{}{ "tenant-abc": map[string]any{
"department": "Innovation", "department": "Innovation",
"position": "Architect", "position": "Architect",
}, },
@@ -165,13 +165,13 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) {
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-dynamic" { if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-dynamic" {
// Capture the claims sent to Hydra // Capture the claims sent to Hydra
body, _ := io.ReadAll(r.Body) body, _ := io.ReadAll(r.Body)
var acceptReq map[string]interface{} var acceptReq map[string]any
json.Unmarshal(body, &acceptReq) json.Unmarshal(body, &acceptReq)
if session, ok := acceptReq["session"].(map[string]interface{}); ok { if session, ok := acceptReq["session"].(map[string]any); ok {
capturedClaims = session["id_token"].(map[string]interface{}) capturedClaims = session["id_token"].(map[string]any)
} }
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"redirect_to": "http://rp/cb", "redirect_to": "http://rp/cb",
}), nil }), nil
} }
@@ -192,10 +192,10 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) {
} }
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{ h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
ID: "user-123", ID: "user-123",
Traits: map[string]interface{}{ Traits: map[string]any{
"email": "user@test.com", "email": "user@test.com",
"name": "Test User", "name": "Test User",
"tenant-abc": map[string]interface{}{ "tenant-abc": map[string]any{
"department": "Innovation", "department": "Innovation",
"position": "Architect", "position": "Architect",
}, },
@@ -205,7 +205,7 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) {
app := fiber.New() app := fiber.New()
app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest) app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest)
reqBody, _ := json.Marshal(map[string]interface{}{ reqBody, _ := json.Marshal(map[string]any{
"consent_challenge": "challenge-dynamic", "consent_challenge": "challenge-dynamic",
"grant_scope": []string{"openid", "profile", "tenant"}, "grant_scope": []string{"openid", "profile", "tenant"},
}) })
@@ -225,20 +225,20 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) {
} }
func TestAcceptConsentRequest_UsesRepresentativeTenantIDInsteadOfClientTenantContext(t *testing.T) { func TestAcceptConsentRequest_UsesRepresentativeTenantIDInsteadOfClientTenantContext(t *testing.T) {
var capturedClaims map[string]interface{} var capturedClaims map[string]any
representativeTenantID := "01970f0a-5c28-74d8-a73a-f6e9e9a7b210" representativeTenantID := "01970f0a-5c28-74d8-a73a-f6e9e9a7b210"
rpContextTenantID := "01970f0b-3448-7bb8-bdc7-16b6a1d2e661" rpContextTenantID := "01970f0b-3448-7bb8-bdc7-16b6a1d2e661"
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-representative-tenant" { if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-representative-tenant" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"challenge": "challenge-representative-tenant", "challenge": "challenge-representative-tenant",
"requested_scope": []string{"openid", "profile", "tenant"}, "requested_scope": []string{"openid", "profile", "tenant"},
"subject": "user-representative", "subject": "user-representative",
"client": map[string]interface{}{ "client": map[string]any{
"client_id": "client-app", "client_id": "client-app",
"metadata": map[string]interface{}{ "metadata": map[string]any{
"tenant_id": rpContextTenantID, "tenant_id": rpContextTenantID,
}, },
}, },
@@ -246,13 +246,13 @@ func TestAcceptConsentRequest_UsesRepresentativeTenantIDInsteadOfClientTenantCon
} }
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-representative-tenant" { if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-representative-tenant" {
body, _ := io.ReadAll(r.Body) body, _ := io.ReadAll(r.Body)
var acceptReq map[string]interface{} var acceptReq map[string]any
json.Unmarshal(body, &acceptReq) json.Unmarshal(body, &acceptReq)
if session, ok := acceptReq["session"].(map[string]interface{}); ok { if session, ok := acceptReq["session"].(map[string]any); ok {
capturedClaims = session["id_token"].(map[string]interface{}) capturedClaims = session["id_token"].(map[string]any)
} }
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"redirect_to": "http://rp/cb", "redirect_to": "http://rp/cb",
}), nil }), nil
} }
@@ -269,12 +269,12 @@ func TestAcceptConsentRequest_UsesRepresentativeTenantIDInsteadOfClientTenantCon
} }
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-representative").Return(&service.KratosIdentity{ h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-representative").Return(&service.KratosIdentity{
ID: "user-representative", ID: "user-representative",
Traits: map[string]interface{}{ Traits: map[string]any{
"email": "user@test.com", "email": "user@test.com",
"name": "Test User", "name": "Test User",
"additionalAppointments": []interface{}{ "additionalAppointments": []any{
map[string]interface{}{"tenantId": representativeTenantID, "isPrimary": true}, map[string]any{"tenantId": representativeTenantID, "isPrimary": true},
map[string]interface{}{"tenantId": rpContextTenantID}, map[string]any{"tenantId": rpContextTenantID},
}, },
}, },
}, nil) }, nil)
@@ -282,7 +282,7 @@ func TestAcceptConsentRequest_UsesRepresentativeTenantIDInsteadOfClientTenantCon
app := fiber.New() app := fiber.New()
app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest) app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest)
reqBody, _ := json.Marshal(map[string]interface{}{ reqBody, _ := json.Marshal(map[string]any{
"consent_challenge": "challenge-representative-tenant", "consent_challenge": "challenge-representative-tenant",
"grant_scope": []string{"openid", "profile"}, "grant_scope": []string{"openid", "profile"},
}) })
@@ -301,7 +301,7 @@ func TestAcceptConsentRequest_UsesRepresentativeTenantIDInsteadOfClientTenantCon
} }
func TestAcceptConsentRequest_IncludesHanmacFamilyTenantClaimDetails(t *testing.T) { func TestAcceptConsentRequest_IncludesHanmacFamilyTenantClaimDetails(t *testing.T) {
var capturedClaims map[string]interface{} var capturedClaims map[string]any
deptID := "01970f0a-5c28-74d8-a73a-f6e9e9a7b210" deptID := "01970f0a-5c28-74d8-a73a-f6e9e9a7b210"
secondDeptID := "01970f0b-3448-7bb8-bdc7-16b6a1d2e661" secondDeptID := "01970f0b-3448-7bb8-bdc7-16b6a1d2e661"
companyID := "01970f08-91da-7286-bd19-882fb98d1f2c" companyID := "01970f08-91da-7286-bd19-882fb98d1f2c"
@@ -309,13 +309,13 @@ func TestAcceptConsentRequest_IncludesHanmacFamilyTenantClaimDetails(t *testing.
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-hanmac-tenant-claim" { if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-hanmac-tenant-claim" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"challenge": "challenge-hanmac-tenant-claim", "challenge": "challenge-hanmac-tenant-claim",
"requested_scope": []string{"openid", "profile", "tenant"}, "requested_scope": []string{"openid", "profile", "tenant"},
"subject": "user-hanmac", "subject": "user-hanmac",
"client": map[string]interface{}{ "client": map[string]any{
"client_id": "hanmac-rp", "client_id": "hanmac-rp",
"metadata": map[string]interface{}{ "metadata": map[string]any{
"tenant_id": deptID, "tenant_id": deptID,
}, },
}, },
@@ -323,13 +323,13 @@ func TestAcceptConsentRequest_IncludesHanmacFamilyTenantClaimDetails(t *testing.
} }
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-hanmac-tenant-claim" { if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-hanmac-tenant-claim" {
body, _ := io.ReadAll(r.Body) body, _ := io.ReadAll(r.Body)
var acceptReq map[string]interface{} var acceptReq map[string]any
json.Unmarshal(body, &acceptReq) json.Unmarshal(body, &acceptReq)
if session, ok := acceptReq["session"].(map[string]interface{}); ok { if session, ok := acceptReq["session"].(map[string]any); ok {
capturedClaims = session["id_token"].(map[string]interface{}) capturedClaims = session["id_token"].(map[string]any)
} }
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"redirect_to": "http://rp/cb", "redirect_to": "http://rp/cb",
}), nil }), nil
} }
@@ -346,11 +346,11 @@ func TestAcceptConsentRequest_IncludesHanmacFamilyTenantClaimDetails(t *testing.
} }
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-hanmac").Return(&service.KratosIdentity{ h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-hanmac").Return(&service.KratosIdentity{
ID: "user-hanmac", ID: "user-hanmac",
Traits: map[string]interface{}{ Traits: map[string]any{
"email": "hanmac-user@example.com", "email": "hanmac-user@example.com",
"name": "한맥 사용자", "name": "한맥 사용자",
"additionalAppointments": []interface{}{ "additionalAppointments": []any{
map[string]interface{}{ map[string]any{
"tenantId": deptID, "tenantId": deptID,
"isPrimary": true, "isPrimary": true,
"isOwner": true, "isOwner": true,
@@ -358,7 +358,7 @@ func TestAcceptConsentRequest_IncludesHanmacFamilyTenantClaimDetails(t *testing.
"jobTitle": "기술기획", "jobTitle": "기술기획",
"position": "팀장", "position": "팀장",
}, },
map[string]interface{}{ map[string]any{
"tenantId": secondDeptID, "tenantId": secondDeptID,
"isPrimary": false, "isPrimary": false,
"isOwner": false, "isOwner": false,
@@ -404,7 +404,7 @@ func TestAcceptConsentRequest_IncludesHanmacFamilyTenantClaimDetails(t *testing.
app := fiber.New() app := fiber.New()
app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest) app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest)
reqBody, _ := json.Marshal(map[string]interface{}{ reqBody, _ := json.Marshal(map[string]any{
"consent_challenge": "challenge-hanmac-tenant-claim", "consent_challenge": "challenge-hanmac-tenant-claim",
"grant_scope": []string{"openid", "profile", "tenant"}, "grant_scope": []string{"openid", "profile", "tenant"},
}) })
@@ -416,10 +416,10 @@ func TestAcceptConsentRequest_IncludesHanmacFamilyTenantClaimDetails(t *testing.
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.NotNil(t, capturedClaims) assert.NotNil(t, capturedClaims)
assert.Equal(t, []interface{}{deptID}, capturedClaims["lead_tenants"]) assert.Equal(t, []any{deptID}, capturedClaims["lead_tenants"])
assert.ElementsMatch(t, []interface{}{deptID, secondDeptID}, capturedClaims["joined_tenants"]) assert.ElementsMatch(t, []any{deptID, secondDeptID}, capturedClaims["joined_tenants"])
tenants := capturedClaims["tenants"].(map[string]interface{}) tenants := capturedClaims["tenants"].(map[string]any)
dept := tenants[deptID].(map[string]interface{}) dept := tenants[deptID].(map[string]any)
assert.Equal(t, true, dept["lead"]) assert.Equal(t, true, dept["lead"])
assert.Equal(t, true, dept["representative"]) assert.Equal(t, true, dept["representative"])
assert.Equal(t, "책임", dept["grade"]) assert.Equal(t, "책임", dept["grade"])
@@ -428,21 +428,21 @@ func TestAcceptConsentRequest_IncludesHanmacFamilyTenantClaimDetails(t *testing.
assert.Equal(t, companyID, dept["parentTenantId"]) assert.Equal(t, companyID, dept["parentTenantId"])
assert.NotContains(t, dept, "parentTenant") assert.NotContains(t, dept, "parentTenant")
ancestors := dept["ancestors"].([]interface{}) ancestors := dept["ancestors"].([]any)
assert.Len(t, ancestors, 2) assert.Len(t, ancestors, 2)
companyAncestor := ancestors[0].(map[string]interface{}) companyAncestor := ancestors[0].(map[string]any)
assert.Equal(t, companyID, companyAncestor["id"]) assert.Equal(t, companyID, companyAncestor["id"])
assert.Equal(t, "hanmac", companyAncestor["slug"]) assert.Equal(t, "hanmac", companyAncestor["slug"])
assert.Equal(t, rootID, companyAncestor["parentTenantId"]) assert.Equal(t, rootID, companyAncestor["parentTenantId"])
assert.NotContains(t, companyAncestor, "parentTenant") assert.NotContains(t, companyAncestor, "parentTenant")
rootAncestor := ancestors[1].(map[string]interface{}) rootAncestor := ancestors[1].(map[string]any)
assert.Equal(t, rootID, rootAncestor["id"]) assert.Equal(t, rootID, rootAncestor["id"])
assert.Equal(t, "hanmac-family", rootAncestor["slug"]) assert.Equal(t, "hanmac-family", rootAncestor["slug"])
assert.Contains(t, rootAncestor, "parentTenantId") assert.Contains(t, rootAncestor, "parentTenantId")
assert.Nil(t, rootAncestor["parentTenantId"]) assert.Nil(t, rootAncestor["parentTenantId"])
assert.NotContains(t, rootAncestor, "parentTenant") assert.NotContains(t, rootAncestor, "parentTenant")
secondDept := tenants[secondDeptID].(map[string]interface{}) secondDept := tenants[secondDeptID].(map[string]any)
assert.Equal(t, false, secondDept["lead"]) assert.Equal(t, false, secondDept["lead"])
assert.Equal(t, false, secondDept["representative"]) assert.Equal(t, false, secondDept["representative"])
assert.Equal(t, "선임", secondDept["grade"]) assert.Equal(t, "선임", secondDept["grade"])
@@ -512,18 +512,18 @@ func TestWithHanmacFamilyTenantClaims_DefaultClaimsOnlyWithoutTenantScope(t *tes
} }
func TestAcceptConsentRequest_IncludesRPProfileClaims(t *testing.T) { func TestAcceptConsentRequest_IncludesRPProfileClaims(t *testing.T) {
var capturedClaims map[string]interface{} var capturedClaims map[string]any
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-rp-profile" { if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-rp-profile" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"challenge": "challenge-rp-profile", "challenge": "challenge-rp-profile",
"requested_scope": []string{"openid", "profile", "tenant"}, "requested_scope": []string{"openid", "profile", "tenant"},
"subject": "user-123", "subject": "user-123",
"client": map[string]interface{}{ "client": map[string]any{
"client_id": "client-app", "client_id": "client-app",
"metadata": map[string]interface{}{ "metadata": map[string]any{
"customUserSchema": []map[string]interface{}{ "customUserSchema": []map[string]any{
{ {
"key": "approvalLevel", "key": "approvalLevel",
"label": "승인 등급", "label": "승인 등급",
@@ -543,13 +543,13 @@ func TestAcceptConsentRequest_IncludesRPProfileClaims(t *testing.T) {
} }
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-rp-profile" { if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-rp-profile" {
body, _ := io.ReadAll(r.Body) body, _ := io.ReadAll(r.Body)
var acceptReq map[string]interface{} var acceptReq map[string]any
json.Unmarshal(body, &acceptReq) json.Unmarshal(body, &acceptReq)
if session, ok := acceptReq["session"].(map[string]interface{}); ok { if session, ok := acceptReq["session"].(map[string]any); ok {
capturedClaims = session["id_token"].(map[string]interface{}) capturedClaims = session["id_token"].(map[string]any)
} }
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"redirect_to": "http://rp/cb", "redirect_to": "http://rp/cb",
}), nil }), nil
} }
@@ -566,7 +566,7 @@ func TestAcceptConsentRequest_IncludesRPProfileClaims(t *testing.T) {
} }
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{ h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
ID: "user-123", ID: "user-123",
Traits: map[string]interface{}{ Traits: map[string]any{
"email": "user@test.com", "email": "user@test.com",
"name": "Test User", "name": "Test User",
}, },
@@ -585,7 +585,7 @@ func TestAcceptConsentRequest_IncludesRPProfileClaims(t *testing.T) {
app := fiber.New() app := fiber.New()
app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest) app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest)
reqBody, _ := json.Marshal(map[string]interface{}{ reqBody, _ := json.Marshal(map[string]any{
"consent_challenge": "challenge-rp-profile", "consent_challenge": "challenge-rp-profile",
"grant_scope": []string{"openid", "profile"}, "grant_scope": []string{"openid", "profile"},
}) })
@@ -597,31 +597,31 @@ func TestAcceptConsentRequest_IncludesRPProfileClaims(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.NotNil(t, capturedClaims) assert.NotNil(t, capturedClaims)
rpProfiles, ok := capturedClaims["rp_profiles"].([]interface{}) rpProfiles, ok := capturedClaims["rp_profiles"].([]any)
assert.True(t, ok) assert.True(t, ok)
assert.Len(t, rpProfiles, 1) assert.Len(t, rpProfiles, 1)
profile := rpProfiles[0].(map[string]interface{}) profile := rpProfiles[0].(map[string]any)
assert.Equal(t, "client-app", profile["client_id"]) assert.Equal(t, "client-app", profile["client_id"])
fields := profile["fields"].(map[string]interface{}) fields := profile["fields"].(map[string]any)
assert.Equal(t, "A", fields["approvalLevel"]) assert.Equal(t, "A", fields["approvalLevel"])
assert.NotContains(t, fields, "internalMemo") assert.NotContains(t, fields, "internalMemo")
repo.AssertExpectations(t) repo.AssertExpectations(t)
} }
func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) { func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) {
var capturedClaims map[string]interface{} var capturedClaims map[string]any
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
// Hydra: Get Consent Request // Hydra: Get Consent Request
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-skip-dynamic" { if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-skip-dynamic" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"challenge": "challenge-skip-dynamic", "challenge": "challenge-skip-dynamic",
"requested_scope": []string{"openid", "profile", "tenant"}, "requested_scope": []string{"openid", "profile", "tenant"},
"skip": true, "skip": true,
"subject": "user-456", "subject": "user-456",
"client": map[string]interface{}{ "client": map[string]any{
"client_id": "skip-app", "client_id": "skip-app",
"metadata": map[string]interface{}{ "metadata": map[string]any{
"tenant_id": "tenant-xyz", "tenant_id": "tenant-xyz",
}, },
}, },
@@ -629,11 +629,11 @@ func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) {
} }
// Kratos: Get Identity // Kratos: Get Identity
if r.URL.Path == "/admin/identities/user-456" { if r.URL.Path == "/admin/identities/user-456" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"id": "user-456", "id": "user-456",
"traits": map[string]interface{}{ "traits": map[string]any{
"email": "skip@test.com", "email": "skip@test.com",
"tenant-xyz": map[string]interface{}{ "tenant-xyz": map[string]any{
"department": "Security", "department": "Security",
"position": "Officer", "position": "Officer",
}, },
@@ -644,13 +644,13 @@ func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) {
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-skip-dynamic" { if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-skip-dynamic" {
// Capture the claims sent to Hydra // Capture the claims sent to Hydra
body, _ := io.ReadAll(r.Body) body, _ := io.ReadAll(r.Body)
var acceptReq map[string]interface{} var acceptReq map[string]any
json.Unmarshal(body, &acceptReq) json.Unmarshal(body, &acceptReq)
if session, ok := acceptReq["session"].(map[string]interface{}); ok { if session, ok := acceptReq["session"].(map[string]any); ok {
capturedClaims = session["id_token"].(map[string]interface{}) capturedClaims = session["id_token"].(map[string]any)
} }
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"redirect_to": "http://rp/cb", "redirect_to": "http://rp/cb",
}), nil }), nil
} }
@@ -671,9 +671,9 @@ func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) {
} }
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-456").Return(&service.KratosIdentity{ h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-456").Return(&service.KratosIdentity{
ID: "user-456", ID: "user-456",
Traits: map[string]interface{}{ Traits: map[string]any{
"email": "skip@test.com", "email": "skip@test.com",
"tenant-xyz": map[string]interface{}{ "tenant-xyz": map[string]any{
"department": "Security", "department": "Security",
"position": "Officer", "position": "Officer",
}, },
@@ -697,19 +697,19 @@ func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) {
} }
func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) { func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) {
var capturedClaims map[string]interface{} var capturedClaims map[string]any
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-configured-claims" { if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-configured-claims" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"challenge": "challenge-configured-claims", "challenge": "challenge-configured-claims",
"requested_scope": []string{"openid", "profile"}, "requested_scope": []string{"openid", "profile"},
"subject": "user-789", "subject": "user-789",
"client": map[string]interface{}{ "client": map[string]any{
"client_id": "client-configured-claims", "client_id": "client-configured-claims",
"metadata": map[string]interface{}{ "metadata": map[string]any{
"tenant_id": "tenant-claims", "tenant_id": "tenant-claims",
"id_token_claims": []map[string]interface{}{ "id_token_claims": []map[string]any{
{ {
"namespace": "top_level", "namespace": "top_level",
"key": "locale", "key": "locale",
@@ -741,13 +741,13 @@ func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) {
} }
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-configured-claims" { if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-configured-claims" {
body, _ := io.ReadAll(r.Body) body, _ := io.ReadAll(r.Body)
var acceptReq map[string]interface{} var acceptReq map[string]any
json.Unmarshal(body, &acceptReq) json.Unmarshal(body, &acceptReq)
if session, ok := acceptReq["session"].(map[string]interface{}); ok { if session, ok := acceptReq["session"].(map[string]any); ok {
capturedClaims = session["id_token"].(map[string]interface{}) capturedClaims = session["id_token"].(map[string]any)
} }
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"redirect_to": "http://rp/cb", "redirect_to": "http://rp/cb",
}), nil }), nil
} }
@@ -768,10 +768,10 @@ func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) {
} }
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-789").Return(&service.KratosIdentity{ h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-789").Return(&service.KratosIdentity{
ID: "user-789", ID: "user-789",
Traits: map[string]interface{}{ Traits: map[string]any{
"email": "real-user@example.com", "email": "real-user@example.com",
"name": "Configured User", "name": "Configured User",
"tenant-claims": map[string]interface{}{ "tenant-claims": map[string]any{
"department": "Platform", "department": "Platform",
}, },
}, },
@@ -780,7 +780,7 @@ func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) {
app := fiber.New() app := fiber.New()
app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest) app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest)
reqBody, _ := json.Marshal(map[string]interface{}{ reqBody, _ := json.Marshal(map[string]any{
"consent_challenge": "challenge-configured-claims", "consent_challenge": "challenge-configured-claims",
"grant_scope": []string{"openid", "profile"}, "grant_scope": []string{"openid", "profile"},
}) })
@@ -796,9 +796,9 @@ func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) {
assert.Equal(t, "ko-KR", capturedClaims["locale"]) assert.Equal(t, "ko-KR", capturedClaims["locale"])
assert.Equal(t, "tenant-claims", capturedClaims["tenant_id"]) assert.Equal(t, "tenant-claims", capturedClaims["tenant_id"])
rpClaims, ok := capturedClaims["rp_claims"].(map[string]interface{}) rpClaims, ok := capturedClaims["rp_claims"].(map[string]any)
if assert.True(t, ok) { if assert.True(t, ok) {
assert.Equal(t, float64(2), rpClaims["tier"]) assert.Equal(t, float64(2), rpClaims["tier"])
assert.Equal(t, []interface{}{"sso", "claims"}, rpClaims["features"]) assert.Equal(t, []any{"sso", "claims"}, rpClaims["features"])
} }
} }

View File

@@ -61,12 +61,12 @@ func newKratosWhoamiTestServer(t *testing.T, identityID string) *httptest.Server
http.Error(w, "missing session", http.StatusUnauthorized) http.Error(w, "missing session", http.StatusUnauthorized)
return return
} }
_ = json.NewEncoder(w).Encode(map[string]interface{}{ _ = json.NewEncoder(w).Encode(map[string]any{
"id": "session-123", "id": "session-123",
"authenticated_at": "2026-05-21T00:00:00Z", "authenticated_at": "2026-05-21T00:00:00Z",
"identity": map[string]interface{}{ "identity": map[string]any{
"id": identityID, "id": identityID,
"traits": map[string]interface{}{ "traits": map[string]any{
"email": "user@example.com", "email": "user@example.com",
}, },
}, },
@@ -113,7 +113,7 @@ func TestEnchantedLinkFlow_Email_Success(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
var initResp map[string]interface{} var initResp map[string]any
json.NewDecoder(resp.Body).Decode(&initResp) json.NewDecoder(resp.Body).Decode(&initResp)
pendingRef := initResp["pendingRef"].(string) pendingRef := initResp["pendingRef"].(string)
assert.NotEmpty(t, pendingRef) assert.NotEmpty(t, pendingRef)
@@ -129,7 +129,7 @@ func TestEnchantedLinkFlow_Email_Success(t *testing.T) {
assert.NotEmpty(t, token) assert.NotEmpty(t, token)
// 2. Verify Magic Link // 2. Verify Magic Link
verifyBody, _ := json.Marshal(map[string]interface{}{ verifyBody, _ := json.Marshal(map[string]any{
"token": token, "token": token,
"verifyOnly": true, "verifyOnly": true,
}) })
@@ -145,7 +145,7 @@ func TestEnchantedLinkFlow_Email_Success(t *testing.T) {
resp, _ = app.Test(req, -1) resp, _ = app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
var pollResp map[string]interface{} var pollResp map[string]any
json.NewDecoder(resp.Body).Decode(&pollResp) json.NewDecoder(resp.Body).Decode(&pollResp)
assert.Equal(t, "ok", pollResp["status"]) assert.Equal(t, "ok", pollResp["status"])
assert.Equal(t, "valid-jwt", pollResp["sessionJwt"]) assert.Equal(t, "valid-jwt", pollResp["sessionJwt"])
@@ -177,7 +177,7 @@ func TestEnchantedLinkFlow_Sms_Success(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
var initResp map[string]interface{} var initResp map[string]any
json.NewDecoder(resp.Body).Decode(&initResp) json.NewDecoder(resp.Body).Decode(&initResp)
assert.NotEmpty(t, initResp["userCode"]) assert.NotEmpty(t, initResp["userCode"])
} }
@@ -193,7 +193,7 @@ func TestVerifyMagicLink_VerifyOnlyWithoutSharedBrowserSessionApprovesOnly(t *te
app := fiber.New() app := fiber.New()
app.Post("/api/v1/auth/magic-link/verify", h.VerifyMagicLink) app.Post("/api/v1/auth/magic-link/verify", h.VerifyMagicLink)
body, _ := json.Marshal(map[string]interface{}{ body, _ := json.Marshal(map[string]any{
"token": "token-123", "token": "token-123",
"verifyOnly": true, "verifyOnly": true,
}) })
@@ -204,7 +204,7 @@ func TestVerifyMagicLink_VerifyOnlyWithoutSharedBrowserSessionApprovesOnly(t *te
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Empty(t, resp.Cookies()) assert.Empty(t, resp.Cookies())
var got map[string]interface{} var got map[string]any
_ = json.NewDecoder(resp.Body).Decode(&got) _ = json.NewDecoder(resp.Body).Decode(&got)
assert.Equal(t, "approved", got["status"]) assert.Equal(t, "approved", got["status"])
assert.Nil(t, got["sessionJwt"]) assert.Nil(t, got["sessionJwt"])
@@ -225,7 +225,7 @@ func TestVerifyMagicLink_VerifyOnlySharedBrowserSameSubjectApprovesOnly(t *testi
app := fiber.New() app := fiber.New()
app.Post("/api/v1/auth/magic-link/verify", h.VerifyMagicLink) app.Post("/api/v1/auth/magic-link/verify", h.VerifyMagicLink)
body, _ := json.Marshal(map[string]interface{}{ body, _ := json.Marshal(map[string]any{
"token": "token-123", "token": "token-123",
"verifyOnly": true, "verifyOnly": true,
}) })
@@ -237,7 +237,7 @@ func TestVerifyMagicLink_VerifyOnlySharedBrowserSameSubjectApprovesOnly(t *testi
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Empty(t, resp.Cookies()) assert.Empty(t, resp.Cookies())
var got map[string]interface{} var got map[string]any
_ = json.NewDecoder(resp.Body).Decode(&got) _ = json.NewDecoder(resp.Body).Decode(&got)
assert.Equal(t, "approved", got["status"]) assert.Equal(t, "approved", got["status"])
assert.Nil(t, got["sessionJwt"]) assert.Nil(t, got["sessionJwt"])
@@ -258,7 +258,7 @@ func TestVerifyMagicLink_VerifyOnlySharedBrowserDifferentSubjectApprovesOnly(t *
app := fiber.New() app := fiber.New()
app.Post("/api/v1/auth/magic-link/verify", h.VerifyMagicLink) app.Post("/api/v1/auth/magic-link/verify", h.VerifyMagicLink)
body, _ := json.Marshal(map[string]interface{}{ body, _ := json.Marshal(map[string]any{
"token": "token-123", "token": "token-123",
"verifyOnly": true, "verifyOnly": true,
}) })
@@ -270,7 +270,7 @@ func TestVerifyMagicLink_VerifyOnlySharedBrowserDifferentSubjectApprovesOnly(t *
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Empty(t, resp.Cookies()) assert.Empty(t, resp.Cookies())
var got map[string]interface{} var got map[string]any
_ = json.NewDecoder(resp.Body).Decode(&got) _ = json.NewDecoder(resp.Body).Decode(&got)
assert.Equal(t, "approved", got["status"]) assert.Equal(t, "approved", got["status"])
assert.Nil(t, got["sessionJwt"]) assert.Nil(t, got["sessionJwt"])
@@ -312,7 +312,7 @@ func TestVerifyLoginCode_VerifyOnlySharedBrowserDifferentSubjectApprovesOnly(t *
app := fiber.New() app := fiber.New()
app.Post("/api/v1/auth/login/code/verify", h.VerifyLoginCode) app.Post("/api/v1/auth/login/code/verify", h.VerifyLoginCode)
body, _ := json.Marshal(map[string]interface{}{ body, _ := json.Marshal(map[string]any{
"loginId": "user@example.com", "loginId": "user@example.com",
"code": "569765", "code": "569765",
"pendingRef": "pending-123", "pendingRef": "pending-123",
@@ -326,7 +326,7 @@ func TestVerifyLoginCode_VerifyOnlySharedBrowserDifferentSubjectApprovesOnly(t *
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Empty(t, resp.Cookies()) assert.Empty(t, resp.Cookies())
var got map[string]interface{} var got map[string]any
_ = json.NewDecoder(resp.Body).Decode(&got) _ = json.NewDecoder(resp.Body).Decode(&got)
assert.Equal(t, "approved", got["status"]) assert.Equal(t, "approved", got["status"])
assert.Nil(t, got["sessionJwt"]) assert.Nil(t, got["sessionJwt"])
@@ -349,7 +349,7 @@ func TestVerifyLoginCode_MapsSmsPhoneBeforeFlowLookup(t *testing.T) {
app := fiber.New() app := fiber.New()
app.Post("/api/v1/auth/login/code/verify", h.VerifyLoginCode) app.Post("/api/v1/auth/login/code/verify", h.VerifyLoginCode)
body, _ := json.Marshal(map[string]interface{}{ body, _ := json.Marshal(map[string]any{
"loginId": "01041585840", "loginId": "01041585840",
"code": "569765", "code": "569765",
"pendingRef": "pending-123", "pendingRef": "pending-123",
@@ -360,7 +360,7 @@ func TestVerifyLoginCode_MapsSmsPhoneBeforeFlowLookup(t *testing.T) {
resp, _ := app.Test(req, -1) resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
var got map[string]interface{} var got map[string]any
_ = json.NewDecoder(resp.Body).Decode(&got) _ = json.NewDecoder(resp.Body).Decode(&got)
assert.Equal(t, "approved", got["status"]) assert.Equal(t, "approved", got["status"])
assert.Equal(t, "pending-123", got["pendingRef"]) assert.Equal(t, "pending-123", got["pendingRef"])
@@ -383,7 +383,7 @@ func TestPollEnchantedLink_ExpiredToken_ReturnsCode(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
var got map[string]interface{} var got map[string]any
json.NewDecoder(resp.Body).Decode(&got) json.NewDecoder(resp.Body).Decode(&got)
assert.Equal(t, "expired_token", got["error"]) assert.Equal(t, "expired_token", got["error"])
assert.Equal(t, "expired_token", got["code"]) assert.Equal(t, "expired_token", got["code"])
@@ -415,7 +415,7 @@ func TestPollEnchantedLink_SharedBrowserSameSubjectIssuesSession(t *testing.T) {
resp, _ := app.Test(req, -1) resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
var got map[string]interface{} var got map[string]any
_ = json.NewDecoder(resp.Body).Decode(&got) _ = json.NewDecoder(resp.Body).Decode(&got)
assert.Equal(t, "ok", got["status"]) assert.Equal(t, "ok", got["status"])
assert.Equal(t, "valid-jwt", got["sessionJwt"]) assert.Equal(t, "valid-jwt", got["sessionJwt"])
@@ -447,7 +447,7 @@ func TestPollEnchantedLink_SharedBrowserDifferentSubjectConflicts(t *testing.T)
resp, _ := app.Test(req, -1) resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusConflict, resp.StatusCode) assert.Equal(t, http.StatusConflict, resp.StatusCode)
var got map[string]interface{} var got map[string]any
_ = json.NewDecoder(resp.Body).Decode(&got) _ = json.NewDecoder(resp.Body).Decode(&got)
assert.Equal(t, "session_subject_conflict", got["code"]) assert.Equal(t, "session_subject_conflict", got["code"])
assert.NotContains(t, redis.data[prefixSession+"pending-123"], "valid-jwt") assert.NotContains(t, redis.data[prefixSession+"pending-123"], "valid-jwt")
@@ -481,7 +481,7 @@ func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) {
Client: domain.HydraClient{ Client: domain.HydraClient{
ClientID: "headless-login-client", ClientID: "headless-login-client",
TokenEndpointAuthMethod: "none", TokenEndpointAuthMethod: "none",
Metadata: map[string]interface{}{ Metadata: map[string]any{
"status": "active", "status": "active",
"headless_login_enabled": true, "headless_login_enabled": true,
"headless_token_endpoint_auth_method": "private_key_jwt", "headless_token_endpoint_auth_method": "private_key_jwt",
@@ -519,7 +519,7 @@ func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
var got map[string]interface{} var got map[string]any
_ = json.NewDecoder(resp.Body).Decode(&got) _ = json.NewDecoder(resp.Body).Decode(&got)
assert.NotEmpty(t, got["pendingRef"]) assert.NotEmpty(t, got["pendingRef"])
_, hasUserCode := got["userCode"] _, hasUserCode := got["userCode"]
@@ -551,7 +551,7 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
ClientID: "headless-login-client", ClientID: "headless-login-client",
ClientName: "local-demo-rp", ClientName: "local-demo-rp",
TokenEndpointAuthMethod: "none", TokenEndpointAuthMethod: "none",
Metadata: map[string]interface{}{ Metadata: map[string]any{
"status": "active", "status": "active",
"headless_login_enabled": true, "headless_login_enabled": true,
"headless_token_endpoint_auth_method": "private_key_jwt", "headless_token_endpoint_auth_method": "private_key_jwt",
@@ -604,7 +604,7 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
resp, _ := app.Test(req, -1) resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
var initResp map[string]interface{} var initResp map[string]any
_ = json.NewDecoder(resp.Body).Decode(&initResp) _ = json.NewDecoder(resp.Body).Decode(&initResp)
pendingRef := initResp["pendingRef"].(string) pendingRef := initResp["pendingRef"].(string)
assert.NotEmpty(t, pendingRef) assert.NotEmpty(t, pendingRef)
@@ -618,7 +618,7 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
} }
assert.NotEmpty(t, token) assert.NotEmpty(t, token)
verifyBody, _ := json.Marshal(map[string]interface{}{ verifyBody, _ := json.Marshal(map[string]any{
"token": token, "token": token,
"verifyOnly": true, "verifyOnly": true,
}) })
@@ -637,7 +637,7 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
resp, _ = app.Test(req, -1) resp, _ = app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
var pollResp map[string]interface{} var pollResp map[string]any
_ = json.NewDecoder(resp.Body).Decode(&pollResp) _ = json.NewDecoder(resp.Body).Decode(&pollResp)
assert.Equal(t, "http://rp/cb", pollResp["redirectTo"]) assert.Equal(t, "http://rp/cb", pollResp["redirectTo"])
assert.Equal(t, "ok", pollResp["status"]) assert.Equal(t, "ok", pollResp["status"])
@@ -677,7 +677,7 @@ func TestHeadlessLinkPoll_ApproverSubjectConflictBlocksMixedRP(t *testing.T) {
ClientID: "headless-login-client", ClientID: "headless-login-client",
ClientName: "local-demo-rp", ClientName: "local-demo-rp",
TokenEndpointAuthMethod: "none", TokenEndpointAuthMethod: "none",
Metadata: map[string]interface{}{ Metadata: map[string]any{
"status": "active", "status": "active",
"headless_login_enabled": true, "headless_login_enabled": true,
"headless_token_endpoint_auth_method": "private_key_jwt", "headless_token_endpoint_auth_method": "private_key_jwt",
@@ -734,7 +734,7 @@ func TestHeadlessLinkPoll_ApproverSubjectConflictBlocksMixedRP(t *testing.T) {
resp, _ := app.Test(req, -1) resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
var initResp map[string]interface{} var initResp map[string]any
_ = json.NewDecoder(resp.Body).Decode(&initResp) _ = json.NewDecoder(resp.Body).Decode(&initResp)
pendingRef := initResp["pendingRef"].(string) pendingRef := initResp["pendingRef"].(string)
assert.NotEmpty(t, pendingRef) assert.NotEmpty(t, pendingRef)
@@ -751,7 +751,7 @@ func TestHeadlessLinkPoll_ApproverSubjectConflictBlocksMixedRP(t *testing.T) {
kratosPublic := newKratosWhoamiTestServer(t, "kratos-userfront-a") kratosPublic := newKratosWhoamiTestServer(t, "kratos-userfront-a")
t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL) t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL)
verifyBody, _ := json.Marshal(map[string]interface{}{ verifyBody, _ := json.Marshal(map[string]any{
"token": token, "token": token,
"verifyOnly": true, "verifyOnly": true,
}) })
@@ -773,7 +773,7 @@ func TestHeadlessLinkPoll_ApproverSubjectConflictBlocksMixedRP(t *testing.T) {
assert.Equal(t, http.StatusConflict, resp.StatusCode) assert.Equal(t, http.StatusConflict, resp.StatusCode)
assert.False(t, acceptCalled) assert.False(t, acceptCalled)
assert.Empty(t, resp.Cookies()) assert.Empty(t, resp.Cookies())
var got map[string]interface{} var got map[string]any
_ = json.NewDecoder(resp.Body).Decode(&got) _ = json.NewDecoder(resp.Body).Decode(&got)
assert.Equal(t, "oidc_subject_conflict", got["code"]) assert.Equal(t, "oidc_subject_conflict", got["code"])
assert.Equal(t, "redirect_to_userfront_login", got["recommendedAction"]) assert.Equal(t, "redirect_to_userfront_login", got["recommendedAction"])
@@ -802,7 +802,7 @@ func TestHeadlessLinkPoll_RequestCookieSubjectConflictBlocksMixedRP(t *testing.T
Client: domain.HydraClient{ Client: domain.HydraClient{
ClientID: "headless-login-client", ClientID: "headless-login-client",
TokenEndpointAuthMethod: "none", TokenEndpointAuthMethod: "none",
Metadata: map[string]interface{}{ Metadata: map[string]any{
"status": "active", "status": "active",
"headless_login_enabled": true, "headless_login_enabled": true,
"headless_token_endpoint_auth_method": "private_key_jwt", "headless_token_endpoint_auth_method": "private_key_jwt",
@@ -857,7 +857,7 @@ func TestHeadlessLinkPoll_RequestCookieSubjectConflictBlocksMixedRP(t *testing.T
resp, _ := app.Test(req, -1) resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
var initResp map[string]interface{} var initResp map[string]any
_ = json.NewDecoder(resp.Body).Decode(&initResp) _ = json.NewDecoder(resp.Body).Decode(&initResp)
pendingRef := initResp["pendingRef"].(string) pendingRef := initResp["pendingRef"].(string)
assert.NotEmpty(t, pendingRef) assert.NotEmpty(t, pendingRef)
@@ -871,7 +871,7 @@ func TestHeadlessLinkPoll_RequestCookieSubjectConflictBlocksMixedRP(t *testing.T
} }
assert.NotEmpty(t, token) assert.NotEmpty(t, token)
verifyBody, _ := json.Marshal(map[string]interface{}{ verifyBody, _ := json.Marshal(map[string]any{
"token": token, "token": token,
"verifyOnly": true, "verifyOnly": true,
}) })
@@ -896,7 +896,7 @@ func TestHeadlessLinkPoll_RequestCookieSubjectConflictBlocksMixedRP(t *testing.T
assert.Equal(t, http.StatusConflict, resp.StatusCode) assert.Equal(t, http.StatusConflict, resp.StatusCode)
assert.False(t, acceptCalled) assert.False(t, acceptCalled)
assert.Empty(t, resp.Cookies()) assert.Empty(t, resp.Cookies())
var got map[string]interface{} var got map[string]any
_ = json.NewDecoder(resp.Body).Decode(&got) _ = json.NewDecoder(resp.Body).Decode(&got)
assert.Equal(t, "oidc_subject_conflict", got["code"]) assert.Equal(t, "oidc_subject_conflict", got["code"])
assert.Equal(t, "kratos-userfront-a", got["currentSubject"]) assert.Equal(t, "kratos-userfront-a", got["currentSubject"])

View File

@@ -32,10 +32,10 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
if r.Header.Get("X-Session-Token") == "" && r.Header.Get("Cookie") == "" { if r.Header.Get("X-Session-Token") == "" && r.Header.Get("Cookie") == "" {
return httpResponse(r, http.StatusUnauthorized, "unauthorized"), nil return httpResponse(r, http.StatusUnauthorized, "unauthorized"), nil
} }
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"identity": map[string]interface{}{ "identity": map[string]any{
"id": "user-123", "id": "user-123",
"traits": map[string]interface{}{ "traits": map[string]any{
"email": "user@test.com", "email": "user@test.com",
}, },
}, },
@@ -43,9 +43,9 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
} }
case "hydra.test": case "hydra.test":
if r.URL.Path == "/oauth2/auth/sessions/consent" { if r.URL.Path == "/oauth2/auth/sessions/consent" {
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{ return httpJSONAny(r, http.StatusOK, []map[string]any{
{ {
"client": map[string]interface{}{ "client": map[string]any{
"client_id": "devfront", "client_id": "devfront",
"client_name": "DevFront", "client_name": "DevFront",
"redirect_uris": []string{ "redirect_uris": []string{
@@ -56,10 +56,10 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
"handled_at": time.Now().Format(time.RFC3339), "handled_at": time.Now().Format(time.RFC3339),
}, },
{ {
"client": map[string]interface{}{ "client": map[string]any{
"client_id": "orgfront", "client_id": "orgfront",
"client_name": "OrgFront", "client_name": "OrgFront",
"metadata": map[string]interface{}{ "metadata": map[string]any{
"auto_login_supported": true, "auto_login_supported": true,
"auto_login_url": "http://localhost:5175/login", "auto_login_url": "http://localhost:5175/login",
}, },
@@ -73,13 +73,13 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
}), nil }), nil
} }
if r.URL.Path == "/admin/clients/client-audit" { if r.URL.Path == "/admin/clients/client-audit" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-audit", "client_id": "client-audit",
"client_name": "Audit App", "client_name": "Audit App",
}), nil }), nil
} }
if r.URL.Path == "/admin/clients/client-consent" { if r.URL.Path == "/admin/clients/client-consent" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-consent", "client_id": "client-consent",
"client_name": "Consent App", "client_name": "Consent App",
}), nil }), nil
@@ -206,17 +206,17 @@ func TestListLinkedRps_EnrichesLogoFromHydraClientWhenConsentSessionOmitsMetadat
switch r.URL.Host { switch r.URL.Host {
case "kratos.test": case "kratos.test":
if r.URL.Path == "/sessions/whoami" { if r.URL.Path == "/sessions/whoami" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"identity": map[string]interface{}{ "identity": map[string]any{
"id": "user-123", "id": "user-123",
}, },
}), nil }), nil
} }
case "hydra.test": case "hydra.test":
if r.URL.Path == "/oauth2/auth/sessions/consent" { if r.URL.Path == "/oauth2/auth/sessions/consent" {
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{ return httpJSONAny(r, http.StatusOK, []map[string]any{
{ {
"client": map[string]interface{}{ "client": map[string]any{
"client_id": "gitea-client", "client_id": "gitea-client",
"client_name": "Gitea", "client_name": "Gitea",
"redirect_uris": []string{ "redirect_uris": []string{
@@ -229,13 +229,13 @@ func TestListLinkedRps_EnrichesLogoFromHydraClientWhenConsentSessionOmitsMetadat
}), nil }), nil
} }
if r.URL.Path == "/clients/gitea-client" { if r.URL.Path == "/clients/gitea-client" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "gitea-client", "client_id": "gitea-client",
"client_name": "Gitea", "client_name": "Gitea",
"redirect_uris": []string{ "redirect_uris": []string{
"https://gitea.example.com/callback", "https://gitea.example.com/callback",
}, },
"metadata": map[string]interface{}{ "metadata": map[string]any{
"logo_url": "https://cdn.example.com/gitea.svg", "logo_url": "https://cdn.example.com/gitea.svg",
}, },
}), nil }), nil

View File

@@ -32,6 +32,7 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gorm.io/gorm"
) )
// --- Mocks --- // --- Mocks ---
@@ -113,7 +114,7 @@ func (m *MockKratosAdminService) ListIdentities(ctx context.Context) ([]service.
return nil, nil return nil, nil
} }
func (m *MockKratosAdminService) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*service.KratosIdentity, error) { func (m *MockKratosAdminService) UpdateIdentity(ctx context.Context, identityID string, traits map[string]any, state string) (*service.KratosIdentity, error) {
return nil, nil return nil, nil
} }
@@ -214,6 +215,8 @@ func (r *passwordLoginUserRepo) FindByCompanyCodes(ctx context.Context, codes []
func (r *passwordLoginUserRepo) Delete(ctx context.Context, id string) error { return nil } func (r *passwordLoginUserRepo) Delete(ctx context.Context, id string) error { return nil }
func (r *passwordLoginUserRepo) DB() *gorm.DB { return nil }
func (r *passwordLoginUserRepo) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error { func (r *passwordLoginUserRepo) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error {
return nil return nil
} }
@@ -474,7 +477,7 @@ func runHeadlessPasswordLoginWithAssertionRequest(
Client: domain.HydraClient{ Client: domain.HydraClient{
ClientID: "headless-login-client", ClientID: "headless-login-client",
TokenEndpointAuthMethod: "none", TokenEndpointAuthMethod: "none",
Metadata: map[string]interface{}{ Metadata: map[string]any{
"status": "active", "status": "active",
"headless_login_enabled": true, "headless_login_enabled": true,
"headless_token_endpoint_auth_method": "private_key_jwt", "headless_token_endpoint_auth_method": "private_key_jwt",
@@ -579,7 +582,7 @@ func runHeadlessPasswordLoginWithAssertionAndLoggerRequest(
Client: domain.HydraClient{ Client: domain.HydraClient{
ClientID: "headless-login-client", ClientID: "headless-login-client",
TokenEndpointAuthMethod: "none", TokenEndpointAuthMethod: "none",
Metadata: map[string]interface{}{ Metadata: map[string]any{
"status": "active", "status": "active",
"headless_login_enabled": true, "headless_login_enabled": true,
"headless_token_endpoint_auth_method": "private_key_jwt", "headless_token_endpoint_auth_method": "private_key_jwt",
@@ -667,7 +670,7 @@ func TestPasswordLogin_OIDC_Success(t *testing.T) {
} }
json.NewEncoder(w).Encode(domain.HydraLoginRequest{ json.NewEncoder(w).Encode(domain.HydraLoginRequest{
Challenge: challenge, Challenge: challenge,
Client: domain.HydraClient{ClientID: "client-1", Metadata: map[string]interface{}{"status": "active"}}, Client: domain.HydraClient{ClientID: "client-1", Metadata: map[string]any{"status": "active"}},
}) })
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut: case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut:
// AcceptLoginRequest // AcceptLoginRequest
@@ -710,7 +713,7 @@ func TestPasswordLogin_OIDC_Success(t *testing.T) {
t.Fatalf("expected 200, got %d, body: %s", resp.StatusCode, string(bodyBytes)) t.Fatalf("expected 200, got %d, body: %s", resp.StatusCode, string(bodyBytes))
} }
var got map[string]interface{} var got map[string]any
json.NewDecoder(resp.Body).Decode(&got) json.NewDecoder(resp.Body).Decode(&got)
if got["redirectTo"] != "http://rp/cb" { if got["redirectTo"] != "http://rp/cb" {
t.Errorf("expected redirectTo http://rp/cb, got %v", got["redirectTo"]) t.Errorf("expected redirectTo http://rp/cb, got %v", got["redirectTo"])
@@ -738,7 +741,7 @@ func TestPasswordLogin_OIDC_AuditIncludesClientMetadata(t *testing.T) {
Client: domain.HydraClient{ Client: domain.HydraClient{
ClientID: "devfront", ClientID: "devfront",
ClientName: "DevFront", ClientName: "DevFront",
Metadata: map[string]interface{}{"status": "active"}, Metadata: map[string]any{"status": "active"},
}, },
}) })
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut: case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut:
@@ -902,7 +905,7 @@ func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) {
Client: domain.HydraClient{ Client: domain.HydraClient{
ClientID: "headless-login-client", ClientID: "headless-login-client",
TokenEndpointAuthMethod: "none", TokenEndpointAuthMethod: "none",
Metadata: map[string]interface{}{ Metadata: map[string]any{
"status": "active", "status": "active",
"headless_login_enabled": true, "headless_login_enabled": true,
"headless_token_endpoint_auth_method": "private_key_jwt", "headless_token_endpoint_auth_method": "private_key_jwt",
@@ -958,7 +961,7 @@ func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) {
t.Fatalf("expected 200, got %d, body: %s", resp.StatusCode, string(bodyBytes)) t.Fatalf("expected 200, got %d, body: %s", resp.StatusCode, string(bodyBytes))
} }
var got map[string]interface{} var got map[string]any
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatalf("failed to decode response: %v", err) t.Fatalf("failed to decode response: %v", err)
} }
@@ -1005,7 +1008,7 @@ func TestHeadlessPasswordLogin_OIDCSubjectConflictBlocksMixedRP(t *testing.T) {
Client: domain.HydraClient{ Client: domain.HydraClient{
ClientID: "headless-login-client", ClientID: "headless-login-client",
TokenEndpointAuthMethod: "none", TokenEndpointAuthMethod: "none",
Metadata: map[string]interface{}{ Metadata: map[string]any{
"status": "active", "status": "active",
"headless_login_enabled": true, "headless_login_enabled": true,
"headless_token_endpoint_auth_method": "private_key_jwt", "headless_token_endpoint_auth_method": "private_key_jwt",
@@ -1051,7 +1054,7 @@ func TestHeadlessPasswordLogin_OIDCSubjectConflictBlocksMixedRP(t *testing.T) {
require.Equal(t, http.StatusConflict, resp.StatusCode) require.Equal(t, http.StatusConflict, resp.StatusCode)
require.False(t, acceptCalled) require.False(t, acceptCalled)
require.Empty(t, resp.Cookies()) require.Empty(t, resp.Cookies())
var got map[string]interface{} var got map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
require.Equal(t, "oidc_subject_conflict", got["code"]) require.Equal(t, "oidc_subject_conflict", got["code"])
require.Equal(t, "redirect_to_userfront_login", got["recommendedAction"]) require.Equal(t, "redirect_to_userfront_login", got["recommendedAction"])
@@ -1090,7 +1093,7 @@ func TestHeadlessPasswordLogin_OIDCSubjectSameAllowsMixedRP(t *testing.T) {
Client: domain.HydraClient{ Client: domain.HydraClient{
ClientID: "headless-login-client", ClientID: "headless-login-client",
TokenEndpointAuthMethod: "none", TokenEndpointAuthMethod: "none",
Metadata: map[string]interface{}{ Metadata: map[string]any{
"status": "active", "status": "active",
"headless_login_enabled": true, "headless_login_enabled": true,
"headless_token_endpoint_auth_method": "private_key_jwt", "headless_token_endpoint_auth_method": "private_key_jwt",
@@ -1134,7 +1137,7 @@ func TestHeadlessPasswordLogin_OIDCSubjectSameAllowsMixedRP(t *testing.T) {
require.Equal(t, http.StatusOK, resp.StatusCode) require.Equal(t, http.StatusOK, resp.StatusCode)
require.Empty(t, resp.Cookies()) require.Empty(t, resp.Cookies())
var got map[string]interface{} var got map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
require.Equal(t, "http://rp/cb", got["redirectTo"]) require.Equal(t, "http://rp/cb", got["redirectTo"])
require.Nil(t, got["sessionJwt"]) require.Nil(t, got["sessionJwt"])
@@ -1161,7 +1164,7 @@ func TestHeadlessPasswordLogin_AuditIncludesClientMetadata(t *testing.T) {
ClientID: "headless-login-client", ClientID: "headless-login-client",
ClientName: "Headless Login Portal", ClientName: "Headless Login Portal",
TokenEndpointAuthMethod: "none", TokenEndpointAuthMethod: "none",
Metadata: map[string]interface{}{ Metadata: map[string]any{
"status": "active", "status": "active",
"headless_login_enabled": true, "headless_login_enabled": true,
"headless_token_endpoint_auth_method": "private_key_jwt", "headless_token_endpoint_auth_method": "private_key_jwt",
@@ -1294,7 +1297,7 @@ func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured(
Client: domain.HydraClient{ Client: domain.HydraClient{
ClientID: "headless-login-client", ClientID: "headless-login-client",
TokenEndpointAuthMethod: "none", TokenEndpointAuthMethod: "none",
Metadata: map[string]interface{}{ Metadata: map[string]any{
"status": "active", "status": "active",
"headless_login_enabled": true, "headless_login_enabled": true,
"headless_token_endpoint_auth_method": "private_key_jwt", "headless_token_endpoint_auth_method": "private_key_jwt",
@@ -1395,7 +1398,7 @@ func TestHeadlessPasswordLogin_RefreshesJWKSWhenSignatureFailsForCachedKid(t *te
Client: domain.HydraClient{ Client: domain.HydraClient{
ClientID: "headless-login-client", ClientID: "headless-login-client",
TokenEndpointAuthMethod: "none", TokenEndpointAuthMethod: "none",
Metadata: map[string]interface{}{ Metadata: map[string]any{
"status": "active", "status": "active",
"headless_login_enabled": true, "headless_login_enabled": true,
"headless_token_endpoint_auth_method": "private_key_jwt", "headless_token_endpoint_auth_method": "private_key_jwt",
@@ -1494,7 +1497,7 @@ func TestHeadlessPasswordLogin_MissingClientAssertionRejected(t *testing.T) {
Client: domain.HydraClient{ Client: domain.HydraClient{
ClientID: "headless-login-client", ClientID: "headless-login-client",
TokenEndpointAuthMethod: "none", TokenEndpointAuthMethod: "none",
Metadata: map[string]interface{}{ Metadata: map[string]any{
"status": "active", "status": "active",
"headless_login_enabled": true, "headless_login_enabled": true,
"headless_token_endpoint_auth_method": "private_key_jwt", "headless_token_endpoint_auth_method": "private_key_jwt",
@@ -1573,7 +1576,7 @@ func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) {
Client: domain.HydraClient{ Client: domain.HydraClient{
ClientID: "headless-login-client", ClientID: "headless-login-client",
TokenEndpointAuthMethod: "none", TokenEndpointAuthMethod: "none",
Metadata: map[string]interface{}{ Metadata: map[string]any{
"status": "active", "status": "active",
"headless_login_enabled": true, "headless_login_enabled": true,
"headless_token_endpoint_auth_method": "private_key_jwt", "headless_token_endpoint_auth_method": "private_key_jwt",
@@ -1995,7 +1998,7 @@ func TestHeadlessPasswordLogin_HeadlessDisabledRejected(t *testing.T) {
Client: domain.HydraClient{ Client: domain.HydraClient{
ClientID: "headless-login-client", ClientID: "headless-login-client",
TokenEndpointAuthMethod: "none", TokenEndpointAuthMethod: "none",
Metadata: map[string]interface{}{ Metadata: map[string]any{
"status": "active", "status": "active",
"headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json", "headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json",
"headless_token_endpoint_auth_method": "private_key_jwt", "headless_token_endpoint_auth_method": "private_key_jwt",
@@ -2048,7 +2051,7 @@ func TestHeadlessPasswordLogin_ClientIDMismatchRejected(t *testing.T) {
Client: domain.HydraClient{ Client: domain.HydraClient{
ClientID: "other-rp", ClientID: "other-rp",
TokenEndpointAuthMethod: "none", TokenEndpointAuthMethod: "none",
Metadata: map[string]interface{}{ Metadata: map[string]any{
"status": "active", "status": "active",
"headless_login_enabled": true, "headless_login_enabled": true,
"headless_token_endpoint_auth_method": "private_key_jwt", "headless_token_endpoint_auth_method": "private_key_jwt",
@@ -2102,7 +2105,7 @@ func TestPasswordLogin_OIDC_InactiveClient(t *testing.T) {
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet { if strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet {
json.NewEncoder(w).Encode(domain.HydraLoginRequest{ json.NewEncoder(w).Encode(domain.HydraLoginRequest{
Client: domain.HydraClient{ClientID: "client-inactive", Metadata: map[string]interface{}{"status": "inactive"}}, Client: domain.HydraClient{ClientID: "client-inactive", Metadata: map[string]any{"status": "inactive"}},
}) })
return return
} }
@@ -2220,7 +2223,7 @@ func TestPasswordLogin_SharedBrowserSameSubjectAllowed(t *testing.T) {
defer resp.Body.Close() defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode) require.Equal(t, http.StatusOK, resp.StatusCode)
var got map[string]interface{} var got map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
require.Equal(t, "valid-jwt", got["sessionJwt"]) require.Equal(t, "valid-jwt", got["sessionJwt"])
mockIdp.AssertExpectations(t) mockIdp.AssertExpectations(t)
@@ -2259,7 +2262,7 @@ func TestPasswordLogin_SharedBrowserDifferentSubjectConflicts(t *testing.T) {
defer resp.Body.Close() defer resp.Body.Close()
require.Equal(t, http.StatusConflict, resp.StatusCode) require.Equal(t, http.StatusConflict, resp.StatusCode)
var got map[string]interface{} var got map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
require.Equal(t, "session_subject_conflict", got["code"]) require.Equal(t, "session_subject_conflict", got["code"])
require.Empty(t, resp.Cookies()) require.Empty(t, resp.Cookies())

View File

@@ -33,10 +33,10 @@ func TestAcceptOidcLoginRequest_CookieOnly(t *testing.T) {
if r.Header.Get("Cookie") == "" { if r.Header.Get("Cookie") == "" {
return httpResponse(r, http.StatusUnauthorized, "missing cookie"), nil return httpResponse(r, http.StatusUnauthorized, "missing cookie"), nil
} }
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"identity": map[string]interface{}{ "identity": map[string]any{
"id": "kratos-123", "id": "kratos-123",
"traits": map[string]interface{}{}, "traits": map[string]any{},
}, },
}), nil }), nil
case "hydra.test": case "hydra.test":
@@ -45,7 +45,7 @@ func TestAcceptOidcLoginRequest_CookieOnly(t *testing.T) {
} }
gotChallenge = r.URL.Query().Get("login_challenge") gotChallenge = r.URL.Query().Get("login_challenge")
body, _ := io.ReadAll(r.Body) body, _ := io.ReadAll(r.Body)
var payload map[string]interface{} var payload map[string]any
_ = json.Unmarshal(body, &payload) _ = json.Unmarshal(body, &payload)
if subject, ok := payload["subject"].(string); ok { if subject, ok := payload["subject"].(string); ok {
gotSubject = subject gotSubject = subject
@@ -117,10 +117,10 @@ func TestAcceptOidcLoginRequest_TokenFallbackToCookie(t *testing.T) {
if r.Header.Get("Cookie") == "" { if r.Header.Get("Cookie") == "" {
return httpResponse(r, http.StatusUnauthorized, "missing cookie"), nil return httpResponse(r, http.StatusUnauthorized, "missing cookie"), nil
} }
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"identity": map[string]interface{}{ "identity": map[string]any{
"id": "kratos-456", "id": "kratos-456",
"traits": map[string]interface{}{}, "traits": map[string]any{},
}, },
}), nil }), nil
case "hydra.test": case "hydra.test":
@@ -128,7 +128,7 @@ func TestAcceptOidcLoginRequest_TokenFallbackToCookie(t *testing.T) {
return httpResponse(r, http.StatusNotFound, "not found"), nil return httpResponse(r, http.StatusNotFound, "not found"), nil
} }
body, _ := io.ReadAll(r.Body) body, _ := io.ReadAll(r.Body)
var payload map[string]interface{} var payload map[string]any
_ = json.Unmarshal(body, &payload) _ = json.Unmarshal(body, &payload)
if subject, ok := payload["subject"].(string); ok { if subject, ok := payload["subject"].(string); ok {
gotSubject = subject gotSubject = subject

View File

@@ -23,10 +23,10 @@ func TestHandleKratosCourierRelay_Email(t *testing.T) {
app.Post("/api/v1/auth/kratos/courier", h.HandleKratosCourierRelay) app.Post("/api/v1/auth/kratos/courier", h.HandleKratosCourierRelay)
// Simulate Kratos Courier Request for Email // Simulate Kratos Courier Request for Email
reqBody := map[string]interface{}{ reqBody := map[string]any{
"recipient": "user@example.com", "recipient": "user@example.com",
"template_type": "verification_code", "template_type": "verification_code",
"template_data": map[string]interface{}{ "template_data": map[string]any{
"verification_code": "123456", "verification_code": "123456",
}, },
"subject": "Verify your email", "subject": "Verify your email",
@@ -50,7 +50,7 @@ func TestVerifySignupCode_Success(t *testing.T) {
// Mock stored code in redis // Mock stored code in redis
// signup:email:user@test.com -> {"code":"654321", "verified":false, "expires_at":...} // signup:email:user@test.com -> {"code":"654321", "verified":false, "expires_at":...}
state := map[string]interface{}{ state := map[string]any{
"code": "654321", "code": "654321",
"verified": false, "verified": false,
"expires_at": 9999999999, // far future "expires_at": 9999999999, // far future
@@ -71,13 +71,13 @@ func TestVerifySignupCode_Success(t *testing.T) {
resp, _ := app.Test(req, -1) resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
var res map[string]interface{} var res map[string]any
json.NewDecoder(resp.Body).Decode(&res) json.NewDecoder(resp.Body).Decode(&res)
assert.True(t, res["success"].(bool)) assert.True(t, res["success"].(bool))
// Check redis state updated to verified // Check redis state updated to verified
val, _ := redis.Get("signup:email:user@test.com") val, _ := redis.Get("signup:email:user@test.com")
var updatedState map[string]interface{} var updatedState map[string]any
json.Unmarshal([]byte(val), &updatedState) json.Unmarshal([]byte(val), &updatedState)
assert.True(t, updatedState["verified"].(bool)) assert.True(t, updatedState["verified"].(bool))
} }
@@ -90,7 +90,7 @@ func TestVerifySignupCode_Invalid(t *testing.T) {
app := fiber.New() app := fiber.New()
app.Post("/api/v1/auth/signup/verify", h.VerifySignupCode) app.Post("/api/v1/auth/signup/verify", h.VerifySignupCode)
stateJSON, _ := json.Marshal(map[string]interface{}{ stateJSON, _ := json.Marshal(map[string]any{
"code": "111111", "code": "111111",
"expires_at": 9999999999, "expires_at": 9999999999,
}) })

View File

@@ -5,6 +5,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"maps"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -33,7 +34,7 @@ func (r *recordingUpdateMeUserRepo) UpdateUserLoginIDs(ctx context.Context, user
func TestUpdateMe_InvalidatesProfileCacheForTokenSession(t *testing.T) { func TestUpdateMe_InvalidatesProfileCacheForTokenSession(t *testing.T) {
token := "token-abc" token := "token-abc"
identityID := "user-1" identityID := "user-1"
traits := map[string]interface{}{ traits := map[string]any{
"email": "qa@example.com", "email": "qa@example.com",
"name": "QA User", "name": "QA User",
"phone_number": "+821012345678", "phone_number": "+821012345678",
@@ -51,8 +52,8 @@ func TestUpdateMe_InvalidatesProfileCacheForTokenSession(t *testing.T) {
if r.Header.Get("X-Session-Token") != token { if r.Header.Get("X-Session-Token") != token {
return httpResponse(r, http.StatusUnauthorized, `{"error":"invalid token"}`), nil return httpResponse(r, http.StatusUnauthorized, `{"error":"invalid token"}`), nil
} }
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"identity": map[string]interface{}{ "identity": map[string]any{
"id": identityID, "id": identityID,
"traits": traits, "traits": traits,
}, },
@@ -62,14 +63,12 @@ func TestUpdateMe_InvalidatesProfileCacheForTokenSession(t *testing.T) {
r.URL.Path == "/admin/identities/"+identityID && r.URL.Path == "/admin/identities/"+identityID &&
r.Method == http.MethodPut: r.Method == http.MethodPut:
var payload struct { var payload struct {
Traits map[string]interface{} `json:"traits"` Traits map[string]any `json:"traits"`
} }
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
return httpResponse(r, http.StatusBadRequest, `{"error":"invalid body"}`), nil return httpResponse(r, http.StatusBadRequest, `{"error":"invalid body"}`), nil
} }
for k, v := range payload.Traits { maps.Copy(traits, payload.Traits)
traits[k] = v
}
return httpResponse(r, http.StatusOK, `{"ok":true}`), nil return httpResponse(r, http.StatusOK, `{"ok":true}`), nil
} }
@@ -93,7 +92,7 @@ func TestUpdateMe_InvalidatesProfileCacheForTokenSession(t *testing.T) {
getResp1, err := app.Test(getReq1, -1) getResp1, err := app.Test(getReq1, -1)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, http.StatusOK, getResp1.StatusCode) require.Equal(t, http.StatusOK, getResp1.StatusCode)
var profile1 map[string]interface{} var profile1 map[string]any
require.NoError(t, json.NewDecoder(getResp1.Body).Decode(&profile1)) require.NoError(t, json.NewDecoder(getResp1.Body).Decode(&profile1))
require.Equal(t, "Old Dept", profile1["department"]) require.Equal(t, "Old Dept", profile1["department"])
@@ -121,7 +120,7 @@ func TestUpdateMe_InvalidatesProfileCacheForTokenSession(t *testing.T) {
getResp2, err := app.Test(getReq2, -1) getResp2, err := app.Test(getReq2, -1)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, http.StatusOK, getResp2.StatusCode) require.Equal(t, http.StatusOK, getResp2.StatusCode)
var profile2 map[string]interface{} var profile2 map[string]any
require.NoError(t, json.NewDecoder(getResp2.Body).Decode(&profile2)) require.NoError(t, json.NewDecoder(getResp2.Body).Decode(&profile2))
require.Equal(t, "New Dept", profile2["department"]) require.Equal(t, "New Dept", profile2["department"])
} }
@@ -129,7 +128,7 @@ func TestUpdateMe_InvalidatesProfileCacheForTokenSession(t *testing.T) {
func TestUpdateMe_SyncsLocalReadModelFields(t *testing.T) { func TestUpdateMe_SyncsLocalReadModelFields(t *testing.T) {
token := "token-sync" token := "token-sync"
identityID := "user-sync" identityID := "user-sync"
traits := map[string]interface{}{ traits := map[string]any{
"email": "sync@example.com", "email": "sync@example.com",
"name": "Old Name", "name": "Old Name",
"phone_number": "+821012345678", "phone_number": "+821012345678",
@@ -148,8 +147,8 @@ func TestUpdateMe_SyncsLocalReadModelFields(t *testing.T) {
if r.Header.Get("X-Session-Token") != token { if r.Header.Get("X-Session-Token") != token {
return httpResponse(r, http.StatusUnauthorized, `{"error":"invalid token"}`), nil return httpResponse(r, http.StatusUnauthorized, `{"error":"invalid token"}`), nil
} }
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"identity": map[string]interface{}{ "identity": map[string]any{
"id": identityID, "id": identityID,
"traits": traits, "traits": traits,
}, },
@@ -159,14 +158,12 @@ func TestUpdateMe_SyncsLocalReadModelFields(t *testing.T) {
r.URL.Path == "/admin/identities/"+identityID && r.URL.Path == "/admin/identities/"+identityID &&
r.Method == http.MethodPut: r.Method == http.MethodPut:
var payload struct { var payload struct {
Traits map[string]interface{} `json:"traits"` Traits map[string]any `json:"traits"`
} }
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
return httpResponse(r, http.StatusBadRequest, `{"error":"invalid body"}`), nil return httpResponse(r, http.StatusBadRequest, `{"error":"invalid body"}`), nil
} }
for k, v := range payload.Traits { maps.Copy(traits, payload.Traits)
traits[k] = v
}
return httpResponse(r, http.StatusOK, `{"ok":true}`), nil return httpResponse(r, http.StatusOK, `{"ok":true}`), nil
} }
@@ -187,7 +184,7 @@ func TestUpdateMe_SyncsLocalReadModelFields(t *testing.T) {
app := fiber.New() app := fiber.New()
app.Put("/api/v1/user/me", h.UpdateMe) app.Put("/api/v1/user/me", h.UpdateMe)
updateBody, _ := json.Marshal(map[string]interface{}{ updateBody, _ := json.Marshal(map[string]any{
"name": "New Name", "name": "New Name",
"phone": "01087654321", "phone": "01087654321",
"department": "New Dept", "department": "New Dept",

View File

@@ -68,7 +68,7 @@ func TestQRLoginFlow_Success(t *testing.T) {
resp, _ := app.Test(req, -1) resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
var initResp map[string]interface{} var initResp map[string]any
json.NewDecoder(resp.Body).Decode(&initResp) json.NewDecoder(resp.Body).Decode(&initResp)
pendingRef := initResp["pendingRef"].(string) pendingRef := initResp["pendingRef"].(string)
@@ -80,7 +80,7 @@ func TestQRLoginFlow_Success(t *testing.T) {
// Expect authorization_pending (400) // Expect authorization_pending (400)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode) assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var pollResp map[string]interface{} var pollResp map[string]any
json.NewDecoder(resp.Body).Decode(&pollResp) json.NewDecoder(resp.Body).Decode(&pollResp)
assert.Equal(t, "authorization_pending", pollResp["error"]) assert.Equal(t, "authorization_pending", pollResp["error"])
assert.Equal(t, "authorization_pending", pollResp["code"]) assert.Equal(t, "authorization_pending", pollResp["code"])
@@ -99,7 +99,7 @@ func TestQRLoginFlow_Success(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
var successResp map[string]interface{} var successResp map[string]any
json.NewDecoder(resp.Body).Decode(&successResp) json.NewDecoder(resp.Body).Decode(&successResp)
assert.Equal(t, "ok", successResp["status"]) assert.Equal(t, "ok", successResp["status"])
assert.Equal(t, "mock-session-jwt", successResp["sessionJwt"]) assert.Equal(t, "mock-session-jwt", successResp["sessionJwt"])
@@ -121,10 +121,10 @@ func TestScanQRLogin_Success(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/sessions/whoami" { if r.URL.Path == "/sessions/whoami" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"identity": map[string]interface{}{ "identity": map[string]any{
"id": "user-123", "id": "user-123",
"traits": map[string]interface{}{ "traits": map[string]any{
"email": "user@example.com", "email": "user@example.com",
}, },
}, },
@@ -153,20 +153,20 @@ func TestResolveConsentSubjects_TokenAndCookie(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Header.Get("X-Session-Token") == "token-123" { if r.Header.Get("X-Session-Token") == "token-123" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"identity": map[string]interface{}{ "identity": map[string]any{
"id": "user-token", "id": "user-token",
"traits": map[string]interface{}{ "traits": map[string]any{
"email": "token@test.com", "email": "token@test.com",
}, },
}, },
}), nil }), nil
} }
if r.Header.Get("Cookie") == "ory_kratos_session=cookie-123" { if r.Header.Get("Cookie") == "ory_kratos_session=cookie-123" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"identity": map[string]interface{}{ "identity": map[string]any{
"id": "user-cookie", "id": "user-cookie",
"traits": map[string]interface{}{ "traits": map[string]any{
"email": "cookie@test.com", "email": "cookie@test.com",
"phone": "010-1234-5678", "phone": "010-1234-5678",
}, },

View File

@@ -92,7 +92,7 @@ func TestSignup_TenantSlugValidation(t *testing.T) {
app.Post("/signup", h.Signup) app.Post("/signup", h.Signup)
// Prepare mock state (already verified email/phone) // Prepare mock state (already verified email/phone)
verifiedState, _ := json.Marshal(map[string]interface{}{ verifiedState, _ := json.Marshal(map[string]any{
"verified": true, "verified": true,
"expires_at": time.Now().Add(time.Hour).Unix(), "expires_at": time.Now().Add(time.Hour).Unix(),
}) })

View File

@@ -17,9 +17,9 @@ const (
clientAllowedTenantsKey = "allowed_tenants" clientAllowedTenantsKey = "allowed_tenants"
) )
func normalizeClientTenantAccessMetadata(metadata map[string]interface{}) (map[string]interface{}, error) { func normalizeClientTenantAccessMetadata(metadata map[string]any) (map[string]any, error) {
if metadata == nil { if metadata == nil {
metadata = map[string]interface{}{} metadata = map[string]any{}
} }
restricted := readMetadataBoolValue(metadata, clientTenantAccessRestrictedKey) restricted := readMetadataBoolValue(metadata, clientTenantAccessRestrictedKey)
@@ -49,7 +49,7 @@ func normalizeClientTenantAccessMetadata(metadata map[string]interface{}) (map[s
return metadata, nil return metadata, nil
} }
func clientTenantAccessRestricted(metadata map[string]interface{}) bool { func clientTenantAccessRestricted(metadata map[string]any) bool {
if metadata == nil { if metadata == nil {
return false return false
} }
@@ -59,7 +59,7 @@ func clientTenantAccessRestricted(metadata map[string]interface{}) bool {
return len(normalizeMetadataStringSlice(metadata[clientAllowedTenantsKey])) > 0 return len(normalizeMetadataStringSlice(metadata[clientAllowedTenantsKey])) > 0
} }
func clientAllowedTenants(metadata map[string]interface{}) []string { func clientAllowedTenants(metadata map[string]any) []string {
if metadata == nil { if metadata == nil {
return nil return nil
} }

View File

@@ -250,7 +250,7 @@ func TestGetConsentRequest_DeniesRestrictedClientWhenProfileResolutionFails(t *t
mockKratos := new(MockKratosAdminService) mockKratos := new(MockKratosAdminService)
mockKratos.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{ mockKratos.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
ID: "user-123", ID: "user-123",
Traits: map[string]interface{}{ Traits: map[string]any{
"email": "user@test.com", "email": "user@test.com",
"tenant_id": "tenant-a", "tenant_id": "tenant-a",
"companyCode": "tenant-a", "companyCode": "tenant-a",

View File

@@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"io" "io"
"net/http" "net/http"
"slices"
"time" "time"
) )
@@ -94,11 +95,8 @@ func (m *mockAuditRepo) FindByUserAndEvents(ctx context.Context, userID string,
var results []domain.AuditLog var results []domain.AuditLog
for _, log := range m.logs { for _, log := range m.logs {
if log.UserID == userID { if log.UserID == userID {
for _, et := range eventTypes { if slices.Contains(eventTypes, log.EventType) {
if log.EventType == et { results = append(results, log)
results = append(results, log)
break
}
} }
} }
} }

View File

@@ -14,6 +14,7 @@ import (
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"maps"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
@@ -104,21 +105,21 @@ type devRPUsageDailyResponse struct {
} }
type clientSummary struct { type clientSummary struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
Status string `json:"status"` Status string `json:"status"`
CreatedAt *time.Time `json:"createdAt,omitempty"` CreatedAt *time.Time `json:"createdAt,omitempty"`
RedirectURIs []string `json:"redirectUris"` RedirectURIs []string `json:"redirectUris"`
Scopes []string `json:"scopes"` Scopes []string `json:"scopes"`
ClientSecret string `json:"clientSecret,omitempty"` ClientSecret string `json:"clientSecret,omitempty"`
TokenEndpointAuthMethod string `json:"tokenEndpointAuthMethod,omitempty"` TokenEndpointAuthMethod string `json:"tokenEndpointAuthMethod,omitempty"`
SkipConsent bool `json:"skipConsent"` SkipConsent bool `json:"skipConsent"`
JwksUri string `json:"jwksUri,omitempty"` JwksUri string `json:"jwksUri,omitempty"`
Jwks interface{} `json:"jwks,omitempty"` Jwks any `json:"jwks,omitempty"`
BackchannelLogoutURI string `json:"backchannelLogoutUri,omitempty"` BackchannelLogoutURI string `json:"backchannelLogoutUri,omitempty"`
BackchannelLogoutSessionRequired bool `json:"backchannelLogoutSessionRequired"` BackchannelLogoutSessionRequired bool `json:"backchannelLogoutSessionRequired"`
Metadata map[string]interface{} `json:"metadata,omitempty"` Metadata map[string]any `json:"metadata,omitempty"`
} }
type clientListResponse struct { type clientListResponse struct {
@@ -198,21 +199,21 @@ type consentListResponse struct {
} }
type clientUpsertRequest struct { type clientUpsertRequest struct {
ID *string `json:"id"` ID *string `json:"id"`
Name *string `json:"name"` Name *string `json:"name"`
Type *string `json:"type"` Type *string `json:"type"`
Status *string `json:"status"` Status *string `json:"status"`
RedirectURIs *[]string `json:"redirectUris"` RedirectURIs *[]string `json:"redirectUris"`
Scopes *[]string `json:"scopes"` Scopes *[]string `json:"scopes"`
GrantTypes *[]string `json:"grantTypes"` GrantTypes *[]string `json:"grantTypes"`
ResponseTypes *[]string `json:"responseTypes"` ResponseTypes *[]string `json:"responseTypes"`
TokenEndpointAuthMethod *string `json:"tokenEndpointAuthMethod"` TokenEndpointAuthMethod *string `json:"tokenEndpointAuthMethod"`
SkipConsent *bool `json:"skipConsent"` SkipConsent *bool `json:"skipConsent"`
JwksUri *string `json:"jwksUri"` JwksUri *string `json:"jwksUri"`
Jwks interface{} `json:"jwks"` Jwks any `json:"jwks"`
BackchannelLogoutURI *string `json:"backchannelLogoutUri"` BackchannelLogoutURI *string `json:"backchannelLogoutUri"`
BackchannelLogoutSessionRequired *bool `json:"backchannelLogoutSessionRequired"` BackchannelLogoutSessionRequired *bool `json:"backchannelLogoutSessionRequired"`
Metadata *map[string]interface{} `json:"metadata"` Metadata *map[string]any `json:"metadata"`
} }
type normalizedIDTokenClaim struct { type normalizedIDTokenClaim struct {
@@ -303,7 +304,7 @@ func tenantIDFromProfile(profile *domain.UserProfileResponse) string {
func addClientIDToSet(set map[string]struct{}, raw any) { func addClientIDToSet(set map[string]struct{}, raw any) {
switch value := raw.(type) { switch value := raw.(type) {
case string: case string:
for _, chunk := range strings.Split(value, ",") { for chunk := range strings.SplitSeq(value, ",") {
id := strings.TrimSpace(chunk) id := strings.TrimSpace(chunk)
if id != "" { if id != "" {
set[id] = struct{}{} set[id] = struct{}{}
@@ -672,7 +673,7 @@ func isProtectedSystemClientID(clientID string) bool {
return ok return ok
} }
func tenantAccessPolicyChanged(before, after map[string]interface{}) bool { func tenantAccessPolicyChanged(before, after map[string]any) bool {
if clientTenantAccessRestricted(before) != clientTenantAccessRestricted(after) { if clientTenantAccessRestricted(before) != clientTenantAccessRestricted(after) {
return true return true
} }
@@ -1162,7 +1163,7 @@ func extractAuthClaimsFromBearer(authHeader string) (string, string) {
} }
} }
var claims map[string]interface{} var claims map[string]any
if err := json.Unmarshal(payload, &claims); err != nil { if err := json.Unmarshal(payload, &claims); err != nil {
return "", "" return "", ""
} }
@@ -1295,10 +1296,7 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
if offset > len(allItems) { if offset > len(allItems) {
offset = len(allItems) offset = len(allItems)
} }
end := offset + limit end := min(offset+limit, len(allItems))
if end > len(allItems) {
end = len(allItems)
}
items = allItems[offset:end] items = allItems[offset:end]
if len(allItems) > end && len(items) > 0 { if len(allItems) > end && len(items) > 0 {
lastTimestamp, lastID := clientSummaryCursorKey(items[len(items)-1]) lastTimestamp, lastID := clientSummaryCursorKey(items[len(items)-1])
@@ -1788,7 +1786,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
metadata := mergeMetadata(nil, req.Metadata) metadata := mergeMetadata(nil, req.Metadata)
if metadata == nil { if metadata == nil {
metadata = map[string]interface{}{} metadata = map[string]any{}
} }
// [Tenant Isolation] Record owner information // [Tenant Isolation] Record owner information
@@ -1858,11 +1856,11 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
ResponseTypes: responseTypes, ResponseTypes: responseTypes,
Scope: strings.Join(scopes, " "), Scope: strings.Join(scopes, " "),
TokenEndpointAuthMethod: tokenAuthMethod, TokenEndpointAuthMethod: tokenAuthMethod,
SkipConsent: boolPtr(valueOrBool(req.SkipConsent, true)), SkipConsent: new(valueOrBool(req.SkipConsent, true)),
JWKSUri: jwksURI, JWKSUri: jwksURI,
JWKS: jwks, JWKS: jwks,
BackChannelLogoutURI: backchannelLogoutURI, BackChannelLogoutURI: backchannelLogoutURI,
BackChannelLogoutSessionRequired: boolPtr(backchannelLogoutSessionRequired), BackChannelLogoutSessionRequired: new(backchannelLogoutSessionRequired),
Metadata: metadata, Metadata: metadata,
} }
@@ -2005,7 +2003,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
metadata := mergeMetadata(current.Metadata, req.Metadata) metadata := mergeMetadata(current.Metadata, req.Metadata)
if status != "" { if status != "" {
if metadata == nil { if metadata == nil {
metadata = map[string]interface{}{} metadata = map[string]any{}
} }
metadata["status"] = status metadata["status"] = status
} }
@@ -2061,11 +2059,11 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
ResponseTypes: derefSlice(req.ResponseTypes, current.ResponseTypes), ResponseTypes: derefSlice(req.ResponseTypes, current.ResponseTypes),
Scope: buildScope(valueOrSlice(req.Scopes, strings.Fields(current.Scope))), Scope: buildScope(valueOrSlice(req.Scopes, strings.Fields(current.Scope))),
TokenEndpointAuthMethod: resolvedTokenAuthMethod, TokenEndpointAuthMethod: resolvedTokenAuthMethod,
SkipConsent: boolPtr(resolvedSkipConsent), SkipConsent: new(resolvedSkipConsent),
JWKSUri: resolvedJWKSURI, JWKSUri: resolvedJWKSURI,
JWKS: resolvedJWKS, JWKS: resolvedJWKS,
BackChannelLogoutURI: strings.TrimSpace(resolvedBackchannelLogoutURI), BackChannelLogoutURI: strings.TrimSpace(resolvedBackchannelLogoutURI),
BackChannelLogoutSessionRequired: boolPtr(resolvedBackchannelLogoutSessionRequired), BackChannelLogoutSessionRequired: new(resolvedBackchannelLogoutSessionRequired),
Metadata: metadata, Metadata: metadata,
} }
if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil { if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil {
@@ -2359,10 +2357,7 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
if offset > len(items) { if offset > len(items) {
offset = len(items) offset = len(items)
} }
end := offset + limit end := min(offset+limit, len(items))
if end > len(items) {
end = len(items)
}
pageItems := items[offset:end] pageItems := items[offset:end]
if len(items) > end && len(pageItems) > 0 { if len(items) > end && len(pageItems) > 0 {
lastTimestamp, lastID := consentSummaryCursorKey(pageItems[len(pageItems)-1]) lastTimestamp, lastID := consentSummaryCursorKey(pageItems[len(pageItems)-1])
@@ -2948,7 +2943,7 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
} }
} }
func readMetadataStringValue(metadata map[string]interface{}, key string) string { func readMetadataStringValue(metadata map[string]any, key string) string {
if metadata == nil { if metadata == nil {
return "" return ""
} }
@@ -2956,7 +2951,7 @@ func readMetadataStringValue(metadata map[string]interface{}, key string) string
return strings.TrimSpace(raw) return strings.TrimSpace(raw)
} }
func readMetadataBoolValue(metadata map[string]interface{}, key string) bool { func readMetadataBoolValue(metadata map[string]any, key string) bool {
if metadata == nil { if metadata == nil {
return false return false
} }
@@ -2964,7 +2959,7 @@ func readMetadataBoolValue(metadata map[string]interface{}, key string) bool {
return value return value
} }
func readStringSliceMetadata(metadata map[string]interface{}, key string) []string { func readStringSliceMetadata(metadata map[string]any, key string) []string {
if metadata == nil { if metadata == nil {
return nil return nil
} }
@@ -2981,7 +2976,7 @@ func readStringSliceMetadata(metadata map[string]interface{}, key string) []stri
} }
} }
return result return result
case []interface{}: case []any:
result := make([]string, 0, len(typed)) result := make([]string, 0, len(typed))
for _, item := range typed { for _, item := range typed {
if str, ok := item.(string); ok { if str, ok := item.(string); ok {
@@ -2996,7 +2991,7 @@ func readStringSliceMetadata(metadata map[string]interface{}, key string) []stri
} }
} }
func readMetadataValueOrNil(metadata map[string]interface{}, key string) interface{} { func readMetadataValueOrNil(metadata map[string]any, key string) any {
if metadata == nil { if metadata == nil {
return nil return nil
} }
@@ -3007,9 +3002,9 @@ func readMetadataValueOrNil(metadata map[string]interface{}, key string) interfa
return value return value
} }
func normalizeBackchannelLogoutMetadata(metadata map[string]interface{}, logoutURI string, sessionRequired bool) (map[string]interface{}, error) { func normalizeBackchannelLogoutMetadata(metadata map[string]any, logoutURI string, sessionRequired bool) (map[string]any, error) {
if metadata == nil { if metadata == nil {
metadata = map[string]interface{}{} metadata = map[string]any{}
} }
trimmedURI := strings.TrimSpace(logoutURI) trimmedURI := strings.TrimSpace(logoutURI)
@@ -3078,7 +3073,7 @@ func isAllowedLocalBackchannelLogoutHost(rawHost string) bool {
return !strings.Contains(host, ".") return !strings.Contains(host, ".")
} }
func normalizeClientAutoLoginMetadata(metadata map[string]interface{}) (map[string]interface{}, error) { func normalizeClientAutoLoginMetadata(metadata map[string]any) (map[string]any, error) {
if metadata == nil { if metadata == nil {
return metadata, nil return metadata, nil
} }
@@ -3105,11 +3100,11 @@ func normalizeClientAutoLoginMetadata(metadata map[string]interface{}) (map[stri
func normalizeHeadlessClientConfig( func normalizeHeadlessClientConfig(
tokenAuthMethod string, tokenAuthMethod string,
jwksURI string, jwksURI string,
jwks interface{}, jwks any,
metadata map[string]interface{}, metadata map[string]any,
) (string, string, interface{}, map[string]interface{}) { ) (string, string, any, map[string]any) {
if metadata == nil { if metadata == nil {
metadata = map[string]interface{}{} metadata = map[string]any{}
} }
delete(metadata, domain.MetadataRequestObjectSigningAlg) delete(metadata, domain.MetadataRequestObjectSigningAlg)
@@ -3145,7 +3140,7 @@ func normalizeHeadlessClientConfig(
return tokenAuthMethod, jwksURI, jwks, metadata return tokenAuthMethod, jwksURI, jwks, metadata
} }
func validateHeadlessClientInput(jwksURI string, jwks interface{}, metadata map[string]interface{}) error { func validateHeadlessClientInput(jwksURI string, jwks any, metadata map[string]any) error {
if !readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) { if !readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) {
return nil return nil
} }
@@ -3164,14 +3159,14 @@ func validateHeadlessClientInput(jwksURI string, jwks interface{}, metadata map[
return nil return nil
} }
func normalizeClientTypeForHeadless(clientType string, metadata map[string]interface{}) string { func normalizeClientTypeForHeadless(clientType string, metadata map[string]any) string {
if readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) { if readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) {
return "private" return "private"
} }
return clientType return clientType
} }
func normalizeIDTokenClaimsMetadata(metadata map[string]interface{}) (map[string]interface{}, error) { func normalizeIDTokenClaimsMetadata(metadata map[string]any) (map[string]any, error) {
if metadata == nil { if metadata == nil {
return nil, nil return nil, nil
} }
@@ -3189,16 +3184,16 @@ func normalizeIDTokenClaimsMetadata(metadata map[string]interface{}) (map[string
return metadata, nil return metadata, nil
} }
func normalizeIDTokenClaims(rawClaims interface{}) ([]normalizedIDTokenClaim, error) { func normalizeIDTokenClaims(rawClaims any) ([]normalizedIDTokenClaim, error) {
rawList, ok := rawClaims.([]interface{}) rawList, ok := rawClaims.([]any)
if !ok { if !ok {
if typedList, ok := rawClaims.([]map[string]interface{}); ok { if typedList, ok := rawClaims.([]map[string]any); ok {
rawList = make([]interface{}, 0, len(typedList)) rawList = make([]any, 0, len(typedList))
for _, item := range typedList { for _, item := range typedList {
rawList = append(rawList, item) rawList = append(rawList, item)
} }
} else if typedList, ok := rawClaims.([]map[string]any); ok { } else if typedList, ok := rawClaims.([]map[string]any); ok {
rawList = make([]interface{}, 0, len(typedList)) rawList = make([]any, 0, len(typedList))
for _, item := range typedList { for _, item := range typedList {
rawList = append(rawList, item) rawList = append(rawList, item)
} }
@@ -3211,13 +3206,11 @@ func normalizeIDTokenClaims(rawClaims interface{}) ([]normalizedIDTokenClaim, er
seen := make(map[string]struct{}, len(rawList)) seen := make(map[string]struct{}, len(rawList))
for _, item := range rawList { for _, item := range rawList {
record, ok := item.(map[string]interface{}) record, ok := item.(map[string]any)
if !ok { if !ok {
if typedRecord, ok := item.(map[string]any); ok { if typedRecord, ok := item.(map[string]any); ok {
record = make(map[string]interface{}, len(typedRecord)) record = make(map[string]any, len(typedRecord))
for key, value := range typedRecord { maps.Copy(record, typedRecord)
record[key] = value
}
} else { } else {
return nil, errors.New("metadata.id_token_claims items must be objects") return nil, errors.New("metadata.id_token_claims items must be objects")
} }
@@ -3271,7 +3264,7 @@ func normalizeIDTokenClaims(rawClaims interface{}) ([]normalizedIDTokenClaim, er
return normalized, nil return normalized, nil
} }
func readInterfaceString(value interface{}, fallback string) string { func readInterfaceString(value any, fallback string) string {
if value == nil { if value == nil {
return fallback return fallback
} }
@@ -3373,8 +3366,9 @@ func valueOr(ptr *string, fallback string) string {
return *ptr return *ptr
} }
//go:fix inline
func boolPtr(value bool) *bool { func boolPtr(value bool) *bool {
return &value return new(value)
} }
func valueOrBool(ptr *bool, fallback bool) bool { func valueOrBool(ptr *bool, fallback bool) bool {
@@ -3398,17 +3392,13 @@ func derefSlice(ptr *[]string, fallback []string) []string {
return *ptr return *ptr
} }
func mergeMetadata(current map[string]interface{}, incoming *map[string]interface{}) map[string]interface{} { func mergeMetadata(current map[string]any, incoming *map[string]any) map[string]any {
if incoming == nil { if incoming == nil {
return current return current
} }
merged := map[string]interface{}{} merged := map[string]any{}
for k, v := range current { maps.Copy(merged, current)
merged[k] = v maps.Copy(merged, *incoming)
}
for k, v := range *incoming {
merged[k] = v
}
return merged return merged
} }
@@ -3433,9 +3423,7 @@ func (h *DevHandler) setAuditDetailsExtra(c *fiber.Ctx, extra map[string]any) {
} }
if existing := c.Locals("audit_details_extra"); existing != nil { if existing := c.Locals("audit_details_extra"); existing != nil {
if m, ok := existing.(map[string]any); ok { if m, ok := existing.(map[string]any); ok {
for k, v := range extra { maps.Copy(m, extra)
m[k] = v
}
c.Locals("audit_details_extra", m) c.Locals("audit_details_extra", m)
return return
} }
@@ -3494,7 +3482,7 @@ func resolveDevAuditClientID(logItem domain.AuditLog, details map[string]any) st
return resolvedID return resolvedID
} }
func resolveStatusFromMetadata(metadata map[string]interface{}) string { func resolveStatusFromMetadata(metadata map[string]any) string {
if metadata != nil { if metadata != nil {
if value, ok := metadata["status"].(string); ok && strings.ToLower(strings.TrimSpace(value)) == "inactive" { if value, ok := metadata["status"].(string); ok && strings.ToLower(strings.TrimSpace(value)) == "inactive" {
return "inactive" return "inactive"

View File

@@ -26,18 +26,18 @@ func TestDevHandler_Isolation(t *testing.T) {
HTTPClient: &http.Client{ HTTPClient: &http.Client{
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients" { if r.Method == http.MethodGet && r.URL.Path == "/clients" {
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{ return httpJSONAny(r, http.StatusOK, []map[string]any{
{ {
"client_id": "client-tenant-a", "client_id": "client-tenant-a",
"client_name": "App Tenant A", "client_name": "App Tenant A",
"token_endpoint_auth_method": "none", // PKCE "token_endpoint_auth_method": "none", // PKCE
"metadata": map[string]interface{}{"tenant_id": "tenant-a"}, "metadata": map[string]any{"tenant_id": "tenant-a"},
}, },
{ {
"client_id": "client-tenant-b", "client_id": "client-tenant-b",
"client_name": "App Tenant B", "client_name": "App Tenant B",
"token_endpoint_auth_method": "none", // PKCE "token_endpoint_auth_method": "none", // PKCE
"metadata": map[string]interface{}{"tenant_id": "tenant-b"}, "metadata": map[string]any{"tenant_id": "tenant-b"},
}, },
}), nil }), nil
} }
@@ -47,15 +47,15 @@ func TestDevHandler_Isolation(t *testing.T) {
if id == "client-tenant-b" { if id == "client-tenant-b" {
tenantID = "tenant-b" tenantID = "tenant-b"
} }
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": id, "client_id": id,
"client_name": "App " + id, "client_name": "App " + id,
"token_endpoint_auth_method": "none", "token_endpoint_auth_method": "none",
"metadata": map[string]interface{}{"tenant_id": tenantID}, "metadata": map[string]any{"tenant_id": tenantID},
}), nil }), nil
} }
if r.Method == http.MethodPost && r.URL.Path == "/clients" { if r.Method == http.MethodPost && r.URL.Path == "/clients" {
var body map[string]interface{} var body map[string]any
json.NewDecoder(r.Body).Decode(&body) json.NewDecoder(r.Body).Decode(&body)
return httpJSONAny(r, http.StatusCreated, body), nil return httpJSONAny(r, http.StatusCreated, body), nil
} }
@@ -205,7 +205,7 @@ func TestDevHandler_Isolation(t *testing.T) {
}) })
app.Put("/api/v1/dev/clients/:id", h.UpdateClient) app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
body, _ := json.Marshal(map[string]interface{}{ body, _ := json.Marshal(map[string]any{
"client_name": "Updated Name", "client_name": "Updated Name",
}) })
@@ -235,7 +235,7 @@ func TestDevHandler_Isolation(t *testing.T) {
}) })
app.Post("/api/v1/dev/clients", h.CreateClient) app.Post("/api/v1/dev/clients", h.CreateClient)
body, _ := json.Marshal(map[string]interface{}{ body, _ := json.Marshal(map[string]any{
"client_name": "New App", "client_name": "New App",
"type": "pkce", "type": "pkce",
"redirectUris": []string{"http://localhost/cb"}, "redirectUris": []string{"http://localhost/cb"},

View File

@@ -35,10 +35,10 @@ func (m *devMockRPUserMetadataRepo) Upsert(ctx context.Context, metadata *domain
func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) { func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/clients/client-1" { if r.URL.Path == "/clients/client-1" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-1", "client_id": "client-1",
"client_name": "Client One", "client_name": "Client One",
"metadata": map[string]interface{}{ "metadata": map[string]any{
"tenant_id": "tenant-1", "tenant_id": "tenant-1",
}, },
}), nil }), nil

View File

@@ -88,7 +88,7 @@ func (m *devMockKratosAdmin) GetIdentity(ctx context.Context, identityID string)
return nil, args.Error(1) return nil, args.Error(1)
} }
func (m *devMockKratosAdmin) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*service.KratosIdentity, error) { func (m *devMockKratosAdmin) UpdateIdentity(ctx context.Context, identityID string, traits map[string]any, state string) (*service.KratosIdentity, error) {
args := m.Called(ctx, identityID, traits, state) args := m.Called(ctx, identityID, traits, state)
if identity, ok := args.Get(0).(*service.KratosIdentity); ok { if identity, ok := args.Get(0).(*service.KratosIdentity); ok {
return identity, args.Error(1) return identity, args.Error(1)
@@ -292,9 +292,9 @@ func TestGetCurrentProfile_PreservesExistingAuditUserContext(t *testing.T) {
func TestListClients_Success(t *testing.T) { func TestListClients_Success(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/clients" { if r.URL.Path == "/clients" {
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{ return httpJSONAny(r, http.StatusOK, []map[string]any{
{"client_id": "client-1", "client_name": "App One", "metadata": map[string]interface{}{"status": "active"}}, {"client_id": "client-1", "client_name": "App One", "metadata": map[string]any{"status": "active"}},
{"client_id": "client-2", "client_name": "App Two", "metadata": map[string]interface{}{"status": "inactive"}}, {"client_id": "client-2", "client_name": "App Two", "metadata": map[string]any{"status": "inactive"}},
}), nil }), nil
} }
return httpJSONAny(r, http.StatusNotFound, nil), nil return httpJSONAny(r, http.StatusNotFound, nil), nil
@@ -326,9 +326,9 @@ func TestListClients_Success(t *testing.T) {
func TestListClients_UserSeesOnlyClientsAllowedByReBAC(t *testing.T) { func TestListClients_UserSeesOnlyClientsAllowedByReBAC(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/clients" { if r.URL.Path == "/clients" {
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{ return httpJSONAny(r, http.StatusOK, []map[string]any{
{"client_id": "client-denied", "client_name": "Denied App", "metadata": map[string]interface{}{"tenant_id": "tenant-a", "status": "active"}}, {"client_id": "client-denied", "client_name": "Denied App", "metadata": map[string]any{"tenant_id": "tenant-a", "status": "active"}},
{"client_id": "client-allowed", "client_name": "Allowed App", "metadata": map[string]interface{}{"tenant_id": "tenant-b", "status": "active"}}, {"client_id": "client-allowed", "client_name": "Allowed App", "metadata": map[string]any{"tenant_id": "tenant-b", "status": "active"}},
}), nil }), nil
} }
return httpJSONAny(r, http.StatusNotFound, nil), nil return httpJSONAny(r, http.StatusNotFound, nil), nil
@@ -796,9 +796,9 @@ func TestUpdateClient_AuditDetailsIncludeGeneralSettingChanges(t *testing.T) {
func TestListClients_ProtectedSystemClientHidden(t *testing.T) { func TestListClients_ProtectedSystemClientHidden(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/clients" { if r.URL.Path == "/clients" {
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{ return httpJSONAny(r, http.StatusOK, []map[string]any{
{"client_id": "oathkeeper-introspect", "client_name": "Internal Client"}, {"client_id": "oathkeeper-introspect", "client_name": "Internal Client"},
{"client_id": "client-1", "client_name": "App One", "metadata": map[string]interface{}{"status": "active"}}, {"client_id": "client-1", "client_name": "App One", "metadata": map[string]any{"status": "active"}},
}), nil }), nil
} }
return httpJSONAny(r, http.StatusNotFound, nil), nil return httpJSONAny(r, http.StatusNotFound, nil), nil
@@ -834,12 +834,12 @@ func TestListClients_ProtectedSystemClientHidden(t *testing.T) {
func TestListClients_ReservedSystemNameAliasHidden(t *testing.T) { func TestListClients_ReservedSystemNameAliasHidden(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/clients" { if r.URL.Path == "/clients" {
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{ return httpJSONAny(r, http.StatusOK, []map[string]any{
{"client_id": "adminfront", "client_name": "AdminFront", "metadata": map[string]interface{}{"status": "active"}}, {"client_id": "adminfront", "client_name": "AdminFront", "metadata": map[string]any{"status": "active"}},
{"client_id": "4f2c9fd6-1111-2222-3333-444444444444", "client_name": "AdminFront", "metadata": map[string]interface{}{"status": "active"}}, {"client_id": "4f2c9fd6-1111-2222-3333-444444444444", "client_name": "AdminFront", "metadata": map[string]any{"status": "active"}},
{"client_id": "devfront", "client_name": "DevFront", "metadata": map[string]interface{}{"status": "active"}}, {"client_id": "devfront", "client_name": "DevFront", "metadata": map[string]any{"status": "active"}},
{"client_id": "7d2c9fd6-1111-2222-3333-444444444444", "client_name": "DevFront", "metadata": map[string]interface{}{"status": "active"}}, {"client_id": "7d2c9fd6-1111-2222-3333-444444444444", "client_name": "DevFront", "metadata": map[string]any{"status": "active"}},
{"client_id": "client-1", "client_name": "App One", "metadata": map[string]interface{}{"status": "active"}}, {"client_id": "client-1", "client_name": "App One", "metadata": map[string]any{"status": "active"}},
}), nil }), nil
} }
return httpJSONAny(r, http.StatusNotFound, nil), nil return httpJSONAny(r, http.StatusNotFound, nil), nil
@@ -878,10 +878,10 @@ func TestListClients_ReservedSystemNameAliasHidden(t *testing.T) {
func TestGetClient_ReservedSystemNameAliasHidden(t *testing.T) { func TestGetClient_ReservedSystemNameAliasHidden(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/clients/4f2c9fd6-1111-2222-3333-444444444444" { if r.URL.Path == "/clients/4f2c9fd6-1111-2222-3333-444444444444" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "4f2c9fd6-1111-2222-3333-444444444444", "client_id": "4f2c9fd6-1111-2222-3333-444444444444",
"client_name": "AdminFront", "client_name": "AdminFront",
"metadata": map[string]interface{}{"status": "active"}, "metadata": map[string]any{"status": "active"},
}), nil }), nil
} }
return httpJSONAny(r, http.StatusNotFound, nil), nil return httpJSONAny(r, http.StatusNotFound, nil), nil
@@ -910,13 +910,13 @@ func TestGetClient_ReservedSystemNameAliasHidden(t *testing.T) {
func TestUpdateClientStatus_Success(t *testing.T) { func TestUpdateClientStatus_Success(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-1", "metadata": map[string]interface{}{"status": "active"}, "client_id": "client-1", "metadata": map[string]any{"status": "active"},
}), nil }), nil
} }
if r.Method == http.MethodPatch && r.URL.Path == "/clients/client-1" { if r.Method == http.MethodPatch && r.URL.Path == "/clients/client-1" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-1", "metadata": map[string]interface{}{"status": "inactive"}, "client_id": "client-1", "metadata": map[string]any{"status": "inactive"},
}), nil }), nil
} }
return httpJSONAny(r, http.StatusNotFound, nil), nil return httpJSONAny(r, http.StatusNotFound, nil), nil
@@ -936,7 +936,7 @@ func TestUpdateClientStatus_Success(t *testing.T) {
}) })
app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus) app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus)
body, _ := json.Marshal(map[string]interface{}{"status": "inactive"}) body, _ := json.Marshal(map[string]any{"status": "inactive"})
req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/client-1/status", bytes.NewReader(body)) req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/client-1/status", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1) resp, _ := app.Test(req, -1)
@@ -950,20 +950,20 @@ func TestUpdateClientStatus_Success(t *testing.T) {
func TestUpdateClientStatus_UserAllowedByStatusPermission(t *testing.T) { func TestUpdateClientStatus_UserAllowedByStatusPermission(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-1", "client_id": "client-1",
"client_name": "App One", "client_name": "App One",
"metadata": map[string]interface{}{ "metadata": map[string]any{
"tenant_id": "tenant-1", "tenant_id": "tenant-1",
"status": "active", "status": "active",
}, },
}), nil }), nil
} }
if r.Method == http.MethodPatch && r.URL.Path == "/clients/client-1" { if r.Method == http.MethodPatch && r.URL.Path == "/clients/client-1" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-1", "client_id": "client-1",
"client_name": "App One", "client_name": "App One",
"metadata": map[string]interface{}{ "metadata": map[string]any{
"tenant_id": "tenant-1", "tenant_id": "tenant-1",
"status": "inactive", "status": "inactive",
}, },
@@ -995,7 +995,7 @@ func TestUpdateClientStatus_UserAllowedByStatusPermission(t *testing.T) {
}) })
app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus) app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus)
body, _ := json.Marshal(map[string]interface{}{"status": "inactive"}) body, _ := json.Marshal(map[string]any{"status": "inactive"})
req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/client-1/status", bytes.NewReader(body)) req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/client-1/status", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1) resp, _ := app.Test(req, -1)
@@ -1010,20 +1010,20 @@ func TestUpdateClientStatus_UserAllowedByStatusPermission(t *testing.T) {
func TestUpdateClientStatus_UserAllowedByEditConfigPermission(t *testing.T) { func TestUpdateClientStatus_UserAllowedByEditConfigPermission(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-1", "client_id": "client-1",
"client_name": "App One", "client_name": "App One",
"metadata": map[string]interface{}{ "metadata": map[string]any{
"tenant_id": "tenant-1", "tenant_id": "tenant-1",
"status": "active", "status": "active",
}, },
}), nil }), nil
} }
if r.Method == http.MethodPatch && r.URL.Path == "/clients/client-1" { if r.Method == http.MethodPatch && r.URL.Path == "/clients/client-1" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-1", "client_id": "client-1",
"client_name": "App One", "client_name": "App One",
"metadata": map[string]interface{}{ "metadata": map[string]any{
"tenant_id": "tenant-1", "tenant_id": "tenant-1",
"status": "inactive", "status": "inactive",
}, },
@@ -1055,7 +1055,7 @@ func TestUpdateClientStatus_UserAllowedByEditConfigPermission(t *testing.T) {
}) })
app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus) app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus)
body, _ := json.Marshal(map[string]interface{}{"status": "inactive"}) body, _ := json.Marshal(map[string]any{"status": "inactive"})
req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/client-1/status", bytes.NewReader(body)) req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/client-1/status", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1) resp, _ := app.Test(req, -1)
@@ -1070,7 +1070,7 @@ func TestUpdateClientStatus_UserAllowedByEditConfigPermission(t *testing.T) {
func TestUpdateClientStatus_ProtectedSystemClientForbidden(t *testing.T) { func TestUpdateClientStatus_ProtectedSystemClientForbidden(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" { if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "oathkeeper-introspect", "client_id": "oathkeeper-introspect",
}), nil }), nil
} }
@@ -1091,7 +1091,7 @@ func TestUpdateClientStatus_ProtectedSystemClientForbidden(t *testing.T) {
}) })
app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus) app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus)
body, _ := json.Marshal(map[string]interface{}{"status": "inactive"}) body, _ := json.Marshal(map[string]any{"status": "inactive"})
req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/oathkeeper-introspect/status", bytes.NewReader(body)) req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/oathkeeper-introspect/status", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1) resp, _ := app.Test(req, -1)
@@ -1102,7 +1102,7 @@ func TestUpdateClientStatus_ProtectedSystemClientForbidden(t *testing.T) {
func TestDeleteClient_Success(t *testing.T) { func TestDeleteClient_Success(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{"client_id": "client-1"}), nil return httpJSONAny(r, http.StatusOK, map[string]any{"client_id": "client-1"}), nil
} }
if r.Method == http.MethodDelete && r.URL.Path == "/clients/client-1" { if r.Method == http.MethodDelete && r.URL.Path == "/clients/client-1" {
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody}, nil return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody}, nil
@@ -1142,7 +1142,7 @@ func TestDeleteClient_Success(t *testing.T) {
func TestDeleteClient_ProtectedSystemClientForbidden(t *testing.T) { func TestDeleteClient_ProtectedSystemClientForbidden(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" { if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{"client_id": "oathkeeper-introspect"}), nil return httpJSONAny(r, http.StatusOK, map[string]any{"client_id": "oathkeeper-introspect"}), nil
} }
return httpJSONAny(r, http.StatusNotFound, nil), nil return httpJSONAny(r, http.StatusNotFound, nil), nil
}) })
@@ -1172,7 +1172,7 @@ func TestDeleteClient_ProtectedSystemClientForbidden(t *testing.T) {
func TestGetClient_ProtectedSystemClientHidden(t *testing.T) { func TestGetClient_ProtectedSystemClientHidden(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" { if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "oathkeeper-introspect", "client_id": "oathkeeper-introspect",
"client_name": "Internal Client", "client_name": "Internal Client",
}), nil }), nil
@@ -1203,10 +1203,10 @@ func TestGetClient_ProtectedSystemClientHidden(t *testing.T) {
func TestGetClient_RPAdminAllowedByKetoViewPermission(t *testing.T) { func TestGetClient_RPAdminAllowedByKetoViewPermission(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-1", "client_id": "client-1",
"client_name": "App One", "client_name": "App One",
"metadata": map[string]interface{}{ "metadata": map[string]any{
"tenant_id": "tenant-b", "tenant_id": "tenant-b",
"status": "active", "status": "active",
}, },
@@ -1248,11 +1248,11 @@ func TestGetClient_RPAdminAllowedByKetoViewPermission(t *testing.T) {
func TestGetClient_RedactsSecretWithoutViewSecretPermission(t *testing.T) { func TestGetClient_RedactsSecretWithoutViewSecretPermission(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-1", "client_id": "client-1",
"client_name": "App One", "client_name": "App One",
"client_secret": "stored-secret", "client_secret": "stored-secret",
"metadata": map[string]interface{}{ "metadata": map[string]any{
"tenant_id": "tenant-1", "tenant_id": "tenant-1",
"status": "active", "status": "active",
}, },
@@ -1297,11 +1297,11 @@ func TestGetClient_RedactsSecretWithoutViewSecretPermission(t *testing.T) {
func TestGetClient_UserAllowedToViewSecretByPermission(t *testing.T) { func TestGetClient_UserAllowedToViewSecretByPermission(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-1", "client_id": "client-1",
"client_name": "App One", "client_name": "App One",
"client_secret": "stored-secret", "client_secret": "stored-secret",
"metadata": map[string]interface{}{ "metadata": map[string]any{
"tenant_id": "tenant-1", "tenant_id": "tenant-1",
"status": "active", "status": "active",
}, },
@@ -1346,10 +1346,10 @@ func TestGetClient_UserAllowedToViewSecretByPermission(t *testing.T) {
func TestRotateClientSecret_Success(t *testing.T) { func TestRotateClientSecret_Success(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{"client_id": "client-1"}), nil return httpJSONAny(r, http.StatusOK, map[string]any{"client_id": "client-1"}), nil
} }
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" { if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" {
var body map[string]interface{} var body map[string]any
json.NewDecoder(r.Body).Decode(&body) json.NewDecoder(r.Body).Decode(&body)
return httpJSONAny(r, http.StatusOK, body), nil return httpJSONAny(r, http.StatusOK, body), nil
} }
@@ -1390,7 +1390,7 @@ func TestRotateClientSecret_Success(t *testing.T) {
func TestCreateClient_RPAdminAllowedByTenantGrantPermission(t *testing.T) { func TestCreateClient_RPAdminAllowedByTenantGrantPermission(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodPost && r.URL.Path == "/clients" { if r.Method == http.MethodPost && r.URL.Path == "/clients" {
var body map[string]interface{} var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body) _ = json.NewDecoder(r.Body).Decode(&body)
body["client_secret"] = "generated-secret" body["client_secret"] = "generated-secret"
return httpJSONAny(r, http.StatusCreated, body), nil return httpJSONAny(r, http.StatusCreated, body), nil
@@ -1443,7 +1443,7 @@ func TestCreateClient_RPAdminAllowedByTenantGrantPermission(t *testing.T) {
func TestCreateClient_ApprovedDeveloperCanCreatePrivateClient(t *testing.T) { func TestCreateClient_ApprovedDeveloperCanCreatePrivateClient(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodPost && r.URL.Path == "/clients" { if r.Method == http.MethodPost && r.URL.Path == "/clients" {
var body map[string]interface{} var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body) _ = json.NewDecoder(r.Body).Decode(&body)
body["client_secret"] = "generated-secret" body["client_secret"] = "generated-secret"
return httpJSONAny(r, http.StatusCreated, body), nil return httpJSONAny(r, http.StatusCreated, body), nil
@@ -1625,11 +1625,11 @@ func TestRevokeDeveloperGrantRelation_DeletesRequiredTenantRelations(t *testing.
func TestGetStats_Success(t *testing.T) { func TestGetStats_Success(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/clients" { if r.URL.Path == "/clients" {
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{ return httpJSONAny(r, http.StatusOK, []map[string]any{
{"client_id": "c1", "metadata": map[string]interface{}{"tenant_id": "t1"}}, {"client_id": "c1", "metadata": map[string]any{"tenant_id": "t1"}},
{"client_id": "c2", "metadata": map[string]interface{}{"tenant_id": "t1"}}, {"client_id": "c2", "metadata": map[string]any{"tenant_id": "t1"}},
{"client_id": "oathkeeper-introspect", "metadata": map[string]interface{}{"tenant_id": "t1"}}, {"client_id": "oathkeeper-introspect", "metadata": map[string]any{"tenant_id": "t1"}},
{"client_id": "c3", "metadata": map[string]interface{}{"tenant_id": "t2"}}, {"client_id": "c3", "metadata": map[string]any{"tenant_id": "t2"}},
}), nil }), nil
} }
return httpJSONAny(r, http.StatusNotFound, nil), nil return httpJSONAny(r, http.StatusNotFound, nil), nil
@@ -1693,9 +1693,9 @@ func TestGetStats_UserScopesAuditMetricsToVisibleClients(t *testing.T) {
now := time.Now() now := time.Now()
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/clients" { if r.URL.Path == "/clients" {
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{ return httpJSONAny(r, http.StatusOK, []map[string]any{
{"client_id": "client-owned", "metadata": map[string]interface{}{"tenant_id": "tenant-a"}}, {"client_id": "client-owned", "metadata": map[string]any{"tenant_id": "tenant-a"}},
{"client_id": "client-other", "metadata": map[string]interface{}{"tenant_id": "tenant-a"}}, {"client_id": "client-other", "metadata": map[string]any{"tenant_id": "tenant-a"}},
}), nil }), nil
} }
return httpJSONAny(r, http.StatusNotFound, nil), nil return httpJSONAny(r, http.StatusNotFound, nil), nil
@@ -1785,9 +1785,9 @@ func TestGetStats_UserScopesAuditMetricsToVisibleClients(t *testing.T) {
func TestGetRPUsageDaily_UserScopesItemsToVisibleClients(t *testing.T) { func TestGetRPUsageDaily_UserScopesItemsToVisibleClients(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/clients" { if r.URL.Path == "/clients" {
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{ return httpJSONAny(r, http.StatusOK, []map[string]any{
{"client_id": "client-owned", "client_name": "Owned App", "metadata": map[string]interface{}{"tenant_id": "tenant-a"}}, {"client_id": "client-owned", "client_name": "Owned App", "metadata": map[string]any{"tenant_id": "tenant-a"}},
{"client_id": "client-other", "client_name": "Other App", "metadata": map[string]interface{}{"tenant_id": "tenant-a"}}, {"client_id": "client-other", "client_name": "Other App", "metadata": map[string]any{"tenant_id": "tenant-a"}},
}), nil }), nil
} }
return httpJSONAny(r, http.StatusNotFound, nil), nil return httpJSONAny(r, http.StatusNotFound, nil), nil
@@ -1997,7 +1997,7 @@ func TestCreateClient_DefaultsSkipConsentToTrue(t *testing.T) {
func TestNormalizeClientAutoLoginMetadata(t *testing.T) { func TestNormalizeClientAutoLoginMetadata(t *testing.T) {
t.Run("keeps supported flag and URL", func(t *testing.T) { t.Run("keeps supported flag and URL", func(t *testing.T) {
metadata, err := normalizeClientAutoLoginMetadata(map[string]interface{}{ metadata, err := normalizeClientAutoLoginMetadata(map[string]any{
"auto_login_supported": true, "auto_login_supported": true,
"auto_login_url": "https://rp.example.com/login?auto=1", "auto_login_url": "https://rp.example.com/login?auto=1",
}) })
@@ -2007,14 +2007,14 @@ func TestNormalizeClientAutoLoginMetadata(t *testing.T) {
}) })
t.Run("requires URL when supported", func(t *testing.T) { t.Run("requires URL when supported", func(t *testing.T) {
_, err := normalizeClientAutoLoginMetadata(map[string]interface{}{ _, err := normalizeClientAutoLoginMetadata(map[string]any{
"auto_login_supported": true, "auto_login_supported": true,
}) })
assert.Error(t, err) assert.Error(t, err)
}) })
t.Run("removes URL when unsupported", func(t *testing.T) { t.Run("removes URL when unsupported", func(t *testing.T) {
metadata, err := normalizeClientAutoLoginMetadata(map[string]interface{}{ metadata, err := normalizeClientAutoLoginMetadata(map[string]any{
"auto_login_supported": false, "auto_login_supported": false,
"auto_login_url": "https://rp.example.com/login?auto=1", "auto_login_url": "https://rp.example.com/login?auto=1",
}) })
@@ -2293,9 +2293,9 @@ func TestCreateClient_NormalizesIDTokenClaimsMetadata(t *testing.T) {
resp, _ := app.Test(req, -1) resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusCreated, resp.StatusCode) assert.Equal(t, http.StatusCreated, resp.StatusCode)
claims, ok := captured.Metadata[domain.MetadataIDTokenClaims].([]interface{}) claims, ok := captured.Metadata[domain.MetadataIDTokenClaims].([]any)
if assert.True(t, ok) && assert.Len(t, claims, 2) { if assert.True(t, ok) && assert.Len(t, claims, 2) {
first, ok := claims[0].(map[string]interface{}) first, ok := claims[0].(map[string]any)
if assert.True(t, ok) { if assert.True(t, ok) {
assert.Equal(t, "top_level", first["namespace"]) assert.Equal(t, "top_level", first["namespace"])
assert.Equal(t, "locale", first["key"]) assert.Equal(t, "locale", first["key"])
@@ -2305,7 +2305,7 @@ func TestCreateClient_NormalizesIDTokenClaimsMetadata(t *testing.T) {
assert.False(t, hasID) assert.False(t, hasID)
} }
second, ok := claims[1].(map[string]interface{}) second, ok := claims[1].(map[string]any)
if assert.True(t, ok) { if assert.True(t, ok) {
assert.Equal(t, "rp_claims", second["namespace"]) assert.Equal(t, "rp_claims", second["namespace"])
assert.Equal(t, "tier", second["key"]) assert.Equal(t, "tier", second["key"])
@@ -2464,7 +2464,7 @@ func TestUpdateClient_AllowsExplicitSkipConsentFalse(t *testing.T) {
Scope: "openid profile", Scope: "openid profile",
TokenEndpointAuthMethod: "none", TokenEndpointAuthMethod: "none",
SkipConsent: &currentSkipConsent, SkipConsent: &currentSkipConsent,
Metadata: map[string]interface{}{"status": "active"}, Metadata: map[string]any{"status": "active"},
}), nil }), nil
} }
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" { if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" {
@@ -2620,7 +2620,7 @@ func TestUpdateClient_RevokesExistingConsentsWhenTenantPolicyChanges(t *testing.
ResponseTypes: []string{"code"}, ResponseTypes: []string{"code"},
Scope: "openid tenant profile email", Scope: "openid tenant profile email",
TokenEndpointAuthMethod: "none", TokenEndpointAuthMethod: "none",
Metadata: map[string]interface{}{ Metadata: map[string]any{
"tenant_access_restricted": true, "tenant_access_restricted": true,
"allowed_tenants": []string{"tenant-a"}, "allowed_tenants": []string{"tenant-a"},
}, },
@@ -2704,7 +2704,7 @@ func TestUpdateClient_DoesNotRevokeConsentsWhenTenantPolicyUnchanged(t *testing.
ResponseTypes: []string{"code"}, ResponseTypes: []string{"code"},
Scope: "openid tenant profile email", Scope: "openid tenant profile email",
TokenEndpointAuthMethod: "none", TokenEndpointAuthMethod: "none",
Metadata: map[string]interface{}{ Metadata: map[string]any{
"tenant_access_restricted": true, "tenant_access_restricted": true,
"allowed_tenants": []string{"tenant-a"}, "allowed_tenants": []string{"tenant-a"},
}, },
@@ -2991,9 +2991,9 @@ func TestListAuditLogs_UserAllowedByRPAuditPermission(t *testing.T) {
} }
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/clients" { if r.URL.Path == "/clients" {
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{ return httpJSONAny(r, http.StatusOK, []map[string]any{
{"client_id": "client-allowed", "client_name": "Allowed App", "metadata": map[string]interface{}{"tenant_id": "tenant-a"}}, {"client_id": "client-allowed", "client_name": "Allowed App", "metadata": map[string]any{"tenant_id": "tenant-a"}},
{"client_id": "client-denied", "client_name": "Denied App", "metadata": map[string]interface{}{"tenant_id": "tenant-b"}}, {"client_id": "client-denied", "client_name": "Denied App", "metadata": map[string]any{"tenant_id": "tenant-b"}},
}), nil }), nil
} }
return httpJSONAny(r, http.StatusNotFound, nil), nil return httpJSONAny(r, http.StatusNotFound, nil), nil
@@ -3124,7 +3124,7 @@ func TestListClientRelations_RPAdminAllowedByViewRelationshipsPermission(t *test
mockKratos := new(devMockKratosAdmin) mockKratos := new(devMockKratosAdmin)
mockKratos.On("GetIdentity", mock.Anything, "user-2").Return(&service.KratosIdentity{ mockKratos.On("GetIdentity", mock.Anything, "user-2").Return(&service.KratosIdentity{
ID: "user-2", ID: "user-2",
Traits: map[string]interface{}{ Traits: map[string]any{
"name": "김용연", "name": "김용연",
"email": "kyy@example.com", "email": "kyy@example.com",
"id": "kyy01", "id": "kyy01",
@@ -3195,7 +3195,7 @@ func TestListClientRelations_DedupesDuplicateRelations(t *testing.T) {
mockKratos := new(devMockKratosAdmin) mockKratos := new(devMockKratosAdmin)
mockKratos.On("GetIdentity", mock.Anything, "user-1").Return(&service.KratosIdentity{ mockKratos.On("GetIdentity", mock.Anything, "user-1").Return(&service.KratosIdentity{
ID: "user-1", ID: "user-1",
Traits: map[string]interface{}{ Traits: map[string]any{
"name": "Tester", "name": "Tester",
"email": "tester@example.com", "email": "tester@example.com",
}, },
@@ -3371,7 +3371,7 @@ func TestSearchUsers_RPAdminSearchByNameOrEmailWithinTenantScope(t *testing.T) {
mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{ mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{
{ {
ID: "user-1", ID: "user-1",
Traits: map[string]interface{}{ Traits: map[string]any{
"name": "Alice Kim", "name": "Alice Kim",
"email": "alice@example.com", "email": "alice@example.com",
"id": "alice01", "id": "alice01",
@@ -3380,7 +3380,7 @@ func TestSearchUsers_RPAdminSearchByNameOrEmailWithinTenantScope(t *testing.T) {
}, },
{ {
ID: "user-2", ID: "user-2",
Traits: map[string]interface{}{ Traits: map[string]any{
"name": "Bob Lee", "name": "Bob Lee",
"email": "bob@example.com", "email": "bob@example.com",
"id": "bob01", "id": "bob01",
@@ -3451,7 +3451,7 @@ func TestSearchUsers_UserAllowedByRPAdminRelation(t *testing.T) {
mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{ mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{
{ {
ID: "target-user", ID: "target-user",
Traits: map[string]interface{}{ Traits: map[string]any{
"name": "김용연", "name": "김용연",
"email": "kyy@example.com", "email": "kyy@example.com",
"id": "kyy01", "id": "kyy01",

View File

@@ -4,6 +4,7 @@ import (
"baron-sso-backend/internal/domain" "baron-sso-backend/internal/domain"
"context" "context"
"fmt" "fmt"
"slices"
"strings" "strings"
) )
@@ -235,10 +236,8 @@ func nextAvailableHanmacLocalPart(base string, usedLocalParts map[string]bool) s
} }
func appendUniqueString(values []string, value string) []string { func appendUniqueString(values []string, value string) []string {
for _, existing := range values { if slices.Contains(values, value) {
if existing == value { return values
return values
}
} }
return append(values, value) return append(values, value)
} }

View File

@@ -13,6 +13,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"maps"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -312,10 +313,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
} }
offset = 0 offset = 0
} else if offset < len(tenants) { } else if offset < len(tenants) {
end := offset + limit end := min(offset+limit, len(tenants))
if end > len(tenants) {
end = len(tenants)
}
tenants = tenants[offset:end] tenants = tenants[offset:end]
if total > int64(end) && len(tenants) > 0 { if total > int64(end) && len(tenants) > 0 {
last := tenants[len(tenants)-1] last := tenants[len(tenants)-1]
@@ -980,12 +978,8 @@ func mergeTenantCSVRecordConfig(current domain.JSONMap, record tenantCSVRecord)
} }
merged := make(domain.JSONMap, len(current)+len(recordConfig)) merged := make(domain.JSONMap, len(current)+len(recordConfig))
for key, value := range current { maps.Copy(merged, current)
merged[key] = value maps.Copy(merged, recordConfig)
}
for key, value := range recordConfig {
merged[key] = value
}
return merged, true, nil return merged, true, nil
} }

View File

@@ -107,6 +107,10 @@ type MockUserRepoForHandler struct {
deletedIDs []string deletedIDs []string
} }
func (m *MockUserRepoForHandler) DB() *gorm.DB {
return nil
}
func (m *MockUserRepoForHandler) Create(ctx context.Context, user *domain.User) error { return nil } func (m *MockUserRepoForHandler) Create(ctx context.Context, user *domain.User) error { return nil }
func (m *MockUserRepoForHandler) Update(ctx context.Context, user *domain.User) error { return nil } func (m *MockUserRepoForHandler) Update(ctx context.Context, user *domain.User) error { return nil }
func (m *MockUserRepoForHandler) Delete(ctx context.Context, id string) error { func (m *MockUserRepoForHandler) Delete(ctx context.Context, id string) error {
@@ -229,7 +233,7 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
app.Post("/tenants", h.CreateTenant) app.Post("/tenants", h.CreateTenant)
input := map[string]interface{}{ input := map[string]any{
"name": "Test Tenant", "name": "Test Tenant",
"slug": "test-tenant", "slug": "test-tenant",
"domains": []string{"test.com"}, "domains": []string{"test.com"},
@@ -244,7 +248,7 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
resp, _ := app.Test(req) resp, _ := app.Test(req)
assert.Equal(t, http.StatusCreated, resp.StatusCode) assert.Equal(t, http.StatusCreated, resp.StatusCode)
var got map[string]interface{} var got map[string]any
json.NewDecoder(resp.Body).Decode(&got) json.NewDecoder(resp.Body).Decode(&got)
assert.Equal(t, "t1", got["id"]) assert.Equal(t, "t1", got["id"])
} }
@@ -1191,7 +1195,7 @@ func TestTenantHandler_ImportTenantsCSVCreatesTenant(t *testing.T) {
resp, _ := app.Test(req) resp, _ := app.Test(req)
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
var got map[string]interface{} var got map[string]any
json.NewDecoder(resp.Body).Decode(&got) json.NewDecoder(resp.Body).Decode(&got)
assert.Equal(t, float64(1), got["created"]) assert.Equal(t, float64(1), got["created"])
assert.Equal(t, float64(0), got["updated"]) assert.Equal(t, float64(0), got["updated"])
@@ -1246,7 +1250,7 @@ func TestTenantHandler_ImportTenantsCSVResolvesParentSlugToID(t *testing.T) {
resp, _ := app.Test(req) resp, _ := app.Test(req)
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
var got map[string]interface{} var got map[string]any
json.NewDecoder(resp.Body).Decode(&got) json.NewDecoder(resp.Body).Decode(&got)
assert.Equal(t, float64(2), got["created"]) assert.Equal(t, float64(2), got["created"])
assert.Equal(t, float64(0), got["failed"]) assert.Equal(t, float64(0), got["failed"])
@@ -1292,7 +1296,7 @@ func TestTenantHandler_ImportTenantsCSVDoesNotAssignCreatorAsOrganizationMember(
resp, _ := app.Test(req) resp, _ := app.Test(req)
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
var got map[string]interface{} var got map[string]any
json.NewDecoder(resp.Body).Decode(&got) json.NewDecoder(resp.Body).Decode(&got)
assert.Equal(t, float64(1), got["created"]) assert.Equal(t, float64(1), got["created"])
assert.Equal(t, float64(0), got["failed"]) assert.Equal(t, float64(0), got["failed"])

View File

@@ -12,6 +12,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"maps"
"net/http" "net/http"
"os" "os"
"regexp" "regexp"
@@ -201,7 +202,7 @@ func metadataBoolFromMap(metadata map[string]any, keys ...string) (bool, bool) {
return false, false return false, false
} }
func roleFromTraits(traits map[string]interface{}) string { func roleFromTraits(traits map[string]any) string {
if role, ok := domain.NormalizeRoleAlias(extractTraitString(traits, "role")); ok { if role, ok := domain.NormalizeRoleAlias(extractTraitString(traits, "role")); ok {
return role return role
} }
@@ -219,7 +220,7 @@ func normalizeAssignableSystemRole(value string) (string, bool) {
return role, role == domain.RoleSuperAdmin || role == domain.RoleUser return role, role == domain.RoleSuperAdmin || role == domain.RoleUser
} }
func gradeFromTraits(traits map[string]interface{}) string { func gradeFromTraits(traits map[string]any) string {
value := strings.TrimSpace(extractTraitString(traits, "grade")) value := strings.TrimSpace(extractTraitString(traits, "grade"))
if value == "" { if value == "" {
return "" return ""
@@ -265,7 +266,7 @@ func tenantSlugPointerFromRequest(tenantSlug *string, legacyCompanyCode *string)
return nil, nil return nil, nil
} }
func identityTenantAccessKeys(traits map[string]interface{}) []string { func identityTenantAccessKeys(traits map[string]any) []string {
keys := make([]string, 0, 2) keys := make([]string, 0, 2)
if tenantID := strings.ToLower(strings.TrimSpace(extractTraitString(traits, "tenant_id"))); tenantID != "" { if tenantID := strings.ToLower(strings.TrimSpace(extractTraitString(traits, "tenant_id"))); tenantID != "" {
keys = append(keys, tenantID) keys = append(keys, tenantID)
@@ -523,10 +524,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
if offset > len(filtered) { if offset > len(filtered) {
offset = len(filtered) offset = len(filtered)
} }
end := offset + limit end := min(offset+limit, len(filtered))
if end > len(filtered) {
end = len(filtered)
}
pageIdentities = filtered[offset:end] pageIdentities = filtered[offset:end]
if total > int64(end) && len(pageIdentities) > 0 { if total > int64(end) && len(pageIdentities) > 0 {
lastTimestamp, lastID := kratosIdentityCursorKey(pageIdentities[len(pageIdentities)-1]) lastTimestamp, lastID := kratosIdentityCursorKey(pageIdentities[len(pageIdentities)-1])
@@ -578,11 +576,32 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error {
} }
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
if err != nil { if err != nil || identity == nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error()) // [FIX] Support fixed UUID lookup fallback
} id, searchErr := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), userID)
if identity == nil { if searchErr == nil && id != "" {
return errorJSON(c, fiber.StatusNotFound, "user not found") identity, err = h.KratosAdmin.GetIdentity(c.Context(), id)
}
if err != nil || identity == nil {
// Second Fallback: By Email from local DB
if h.UserRepo != nil {
local, _ := h.UserRepo.FindByID(c.Context(), userID)
if local != nil && local.Email != "" {
id, _ = h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), local.Email)
if id != "" {
identity, err = h.KratosAdmin.GetIdentity(c.Context(), id)
}
}
}
}
if err != nil || identity == nil {
if identity == nil {
return errorJSON(c, fiber.StatusNotFound, "user not found")
}
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
} }
// [New] Check access scope // [New] Check access scope
@@ -685,7 +704,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
role = normalizedRole role = normalizedRole
} }
attributes := map[string]interface{}{ attributes := map[string]any{
"department": req.Department, "department": req.Department,
"grade": strings.TrimSpace(req.Grade), "grade": strings.TrimSpace(req.Grade),
"position": req.Position, "position": req.Position,
@@ -775,7 +794,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
if tenantID != "" && h.TenantService != nil { if tenantID != "" && h.TenantService != nil {
tenant, err := h.TenantService.GetTenant(c.Context(), tenantID) tenant, err := h.TenantService.GetTenant(c.Context(), tenantID)
if err == nil && tenant != nil { if err == nil && tenant != nil {
if schema, ok := tenant.Config["userSchema"].([]interface{}); ok { if schema, ok := tenant.Config["userSchema"].([]any); ok {
if err := h.validateMetadata(req.Metadata, schema, true); err != nil { if err := h.validateMetadata(req.Metadata, schema, true); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed: "+err.Error()) return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed: "+err.Error())
} }
@@ -850,6 +869,8 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
} }
type bulkUserItem struct { type bulkUserItem struct {
ID string `json:"id"`
UUID string `json:"uuid"`
Email string `json:"email"` Email string `json:"email"`
LoginID string `json:"loginId"` LoginID string `json:"loginId"`
Name string `json:"name"` Name string `json:"name"`
@@ -876,6 +897,7 @@ type bulkUserResult struct {
Success bool `json:"success"` Success bool `json:"success"`
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
UserID string `json:"userId,omitempty"` UserID string `json:"userId,omitempty"`
ModifiedFields []string `json:"modifiedFields,omitempty"`
} }
func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error { func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
@@ -912,7 +934,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
ID string ID string
Slug string Slug string
Name string Name string
Schema []interface{} Schema []any
Groups []domain.UserGroup Groups []domain.UserGroup
LoginIDField string LoginIDField string
} }
@@ -926,7 +948,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
Slug: tenant.Slug, Slug: tenant.Slug,
Name: tenant.Name, Name: tenant.Name,
} }
if s, ok := tenant.Config["userSchema"].([]interface{}); ok { if s, ok := tenant.Config["userSchema"].([]any); ok {
tItem.Schema = s tItem.Schema = s
} }
if lf, ok := tenant.Config["loginIdField"].(string); ok { if lf, ok := tenant.Config["loginIdField"].(string); ok {
@@ -1012,6 +1034,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
} }
for _, item := range req.Users { for _, item := range req.Users {
var identityID string
email := strings.TrimSpace(item.Email) email := strings.TrimSpace(item.Email)
name := strings.TrimSpace(item.Name) name := strings.TrimSpace(item.Name)
tenantID := strings.TrimSpace(item.TenantID) tenantID := strings.TrimSpace(item.TenantID)
@@ -1200,7 +1223,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
role = "user" role = "user"
} }
attributes := map[string]interface{}{ attributes := map[string]any{
"department": dept, "department": dept,
"grade": strings.TrimSpace(item.Grade), "grade": strings.TrimSpace(item.Grade),
"position": strings.TrimSpace(item.Position), "position": strings.TrimSpace(item.Position),
@@ -1222,13 +1245,27 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
userPhone := normalizePhoneNumber(item.Phone) userPhone := normalizePhoneNumber(item.Phone)
// Validate provided UUID if any
requestedID := strings.TrimSpace(item.ID)
if requestedID == "" {
requestedID = strings.TrimSpace(item.UUID)
}
if requestedID != "" {
// Basic UUID format validation
matched, _ := regexp.MatchString(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`, strings.ToLower(requestedID))
if !matched {
results = append(results, bulkUserResult{Email: userEmail, Success: false, Message: "invalid UUID format: " + requestedID})
continue
}
}
// Validate all collected LoginIDs // Validate all collected LoginIDs
if collectedIDs, ok := attributes["custom_login_ids"].([]string); ok { if collectedIDs, ok := attributes["custom_login_ids"].([]string); ok {
valid := true valid := true
// Collect all emails // Collect all emails
allEmails := []string{userEmail} allEmails := []string{userEmail}
if secondaryRaw, exists := item.Metadata["sub_email"]; exists { if secondaryRaw, exists := item.Metadata["sub_email"]; exists {
if secondaryEmails, ok := secondaryRaw.([]interface{}); ok { if secondaryEmails, ok := secondaryRaw.([]any); ok {
for _, se := range secondaryEmails { for _, se := range secondaryEmails {
if seStr, ok := se.(string); ok { if seStr, ok := se.(string); ok {
allEmails = append(allEmails, seStr) allEmails = append(allEmails, seStr)
@@ -1251,32 +1288,125 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
} }
} }
identityID, err := h.OryProvider.CreateUser(&domain.BrokerUser{ resultStatus := "created"
Email: userEmail, // 1. Search-first for Idempotency and Fixed UUID Guarantee
Name: item.Name, if requestedID != "" {
PhoneNumber: userPhone, // Use h.KratosAdmin to search for existing ID or ExternalID
Attributes: attributes, existingID, _ := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), requestedID)
}, password) if existingID != "" {
if err != nil { // Verify it's the same user (optional, but safer)
// 만약 이미 존재하는 사용자라면 로컬 DB 및 Keto 관계만 업데이트(Sync)를 시도 identityID = existingID
if strings.Contains(err.Error(), "409") || strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "exists already") { resultStatus = "updated"
identityID, err = h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), userEmail) slog.Info("BulkCreate: Found existing identity by UUID/Identifier", "requestedID", requestedID, "identityID", identityID)
if err != nil || identityID == "" { }
results = append(results, bulkUserResult{Email: userEmail, OriginalEmail: emailEvaluation.OriginalEmail, SuggestedEmail: emailEvaluation.SuggestedEmail, Status: "blockingError", Warnings: emailEvaluation.Warnings, Success: false, Message: "이미 다른 사용자가 해당 식별자(이메일/사번 등)를 사용 중입니다."}) }
if identityID == "" {
var err error
identityID, err = h.OryProvider.CreateUser(&domain.BrokerUser{
ID: requestedID,
Email: userEmail,
Name: item.Name,
PhoneNumber: userPhone,
Attributes: attributes,
}, password)
if err != nil {
// 만약 이미 존재하는 사용자라면 로컬 DB 및 Keto 관계만 업데이트(Sync)를 시도
if strings.Contains(err.Error(), "409") || strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "exists already") || strings.Contains(err.Error(), "external_id") {
// Detect if it's a UUID conflict or Identifier (Email/LoginID) conflict
if requestedID != "" && (strings.Contains(err.Error(), requestedID) || strings.Contains(err.Error(), "uuid already exists") || strings.Contains(err.Error(), "external_id")) {
// Check if the EXISTING user with this UUID is actually the same person (same email)
existingID, lookupErr := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), userEmail)
if lookupErr == nil && existingID != "" {
identityID = existingID
resultStatus = "updated"
slog.Info("BulkCreate: Conflict detected but same email, reusing identity", "email", userEmail, "identityID", identityID)
} else {
results = append(results, bulkUserResult{Email: userEmail, OriginalEmail: emailEvaluation.OriginalEmail, SuggestedEmail: emailEvaluation.SuggestedEmail, Status: "blockingError", Warnings: emailEvaluation.Warnings, Success: false, Message: "Conflict: UUID already exists (" + requestedID + ")"})
continue
}
} else {
identityID, err = h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), userEmail)
if err != nil || identityID == "" {
results = append(results, bulkUserResult{Email: userEmail, OriginalEmail: emailEvaluation.OriginalEmail, SuggestedEmail: emailEvaluation.SuggestedEmail, Status: "blockingError", Warnings: emailEvaluation.Warnings, Success: false, Message: "이미 다른 사용자가 해당 식별자(이메일/사번 등)를 사용 중입니다."})
continue
}
resultStatus = "updated"
slog.Info("BulkCreate: User already exists by identifier, reusing identity", "email", userEmail, "identityID", identityID)
}
} else {
results = append(results, bulkUserResult{Email: userEmail, OriginalEmail: emailEvaluation.OriginalEmail, SuggestedEmail: emailEvaluation.SuggestedEmail, Status: emailEvaluation.Status, Warnings: emailEvaluation.Warnings, Success: false, Message: err.Error()})
continue continue
} }
slog.Info("BulkCreate: User already exists, syncing local DB and Keto", "email", email, "identityID", identityID)
} else { } else {
results = append(results, bulkUserResult{Email: userEmail, OriginalEmail: emailEvaluation.OriginalEmail, SuggestedEmail: emailEvaluation.SuggestedEmail, Status: emailEvaluation.Status, Warnings: emailEvaluation.Warnings, Success: false, Message: err.Error()}) resultStatus = "created"
continue slog.Info("BulkCreate: New identity created", "email", userEmail, "identityID", identityID)
}
} else {
slog.Info("BulkCreate: Existing identity found by search-first", "email", userEmail, "identityID", identityID)
}
var modifiedFields []string
isRestoration := false
if resultStatus == "updated" && identityID != "" {
existing, err := h.KratosAdmin.GetIdentity(c.Context(), identityID)
if err == nil && existing != nil {
// 1. Check for Restoration (Soft-deleted in local DB)
if h.UserRepo != nil {
var existingLocal domain.User
err := h.UserRepo.DB().Unscoped().Where("id = ?", identityID).First(&existingLocal).Error
if err == nil {
// Check if it was soft-deleted
if existingLocal.DeletedAt.Valid {
isRestoration = true
modifiedFields = append(modifiedFields, "Status")
}
}
}
// 2. Compare Traits
if name != "" && !strings.EqualFold(extractTraitString(existing.Traits, "name"), name) {
modifiedFields = append(modifiedFields, "Name")
}
if item.Phone != "" && normalizePhoneNumber(extractTraitString(existing.Traits, "phone_number")) != normalizePhoneNumber(item.Phone) {
modifiedFields = append(modifiedFields, "Phone")
}
if dept != "" && !strings.EqualFold(extractTraitString(existing.Traits, "department"), dept) {
modifiedFields = append(modifiedFields, "Department")
}
if item.Grade != "" && !strings.EqualFold(gradeFromTraits(existing.Traits), strings.TrimSpace(item.Grade)) {
modifiedFields = append(modifiedFields, "Grade")
}
if item.Position != "" && !strings.EqualFold(extractTraitString(existing.Traits, "position"), strings.TrimSpace(item.Position)) {
modifiedFields = append(modifiedFields, "Position")
}
if item.JobTitle != "" && !strings.EqualFold(extractTraitString(existing.Traits, "jobTitle"), strings.TrimSpace(item.JobTitle)) {
modifiedFields = append(modifiedFields, "JobTitle")
}
if tItem.ID != "" && extractTraitString(existing.Traits, "tenant_id") != tItem.ID {
modifiedFields = append(modifiedFields, "Tenant")
}
if role != "" && !strings.EqualFold(roleFromTraits(existing.Traits), role) {
modifiedFields = append(modifiedFields, "Role")
}
// 3. Finalize Status: If no fields actually changed, it's "unchanged"
if len(modifiedFields) == 0 && !isRestoration {
resultStatus = "unchanged"
}
} }
} }
// [CRITICAL FIX] Sync to local DB directly using current data // [CRITICAL FIX] Sync to local DB directly using current data
// Don't fetch from Kratos here as it might have propagation lag // Use the REQUESTED UUID as the primary ID for Baron SSO if provided
targetLocalID := identityID
if requestedID != "" {
targetLocalID = requestedID
}
if h.UserRepo != nil { if h.UserRepo != nil {
localUser := &domain.User{ localUser := &domain.User{
ID: identityID, ID: targetLocalID,
Email: userEmail, Email: userEmail,
Name: name, Name: name,
Phone: normalizePhoneNumber(item.Phone), Phone: normalizePhoneNumber(item.Phone),
@@ -1295,9 +1425,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
// Merge metadata // Merge metadata
localUser.Metadata = make(domain.JSONMap) localUser.Metadata = make(domain.JSONMap)
for k, v := range item.Metadata { maps.Copy(localUser.Metadata, item.Metadata)
localUser.Metadata[k] = v
}
if err := h.UserRepo.Update(c.Context(), localUser); err != nil { if err := h.UserRepo.Update(c.Context(), localUser); err != nil {
slog.Error("Failed to sync bulk user to local DB", "email", email, "error", err) slog.Error("Failed to sync bulk user to local DB", "email", email, "error", err)
@@ -1313,9 +1441,30 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
for i := range loginIDRecords { for i := range loginIDRecords {
loginIDRecords[i].UserID = localUser.ID loginIDRecords[i].UserID = localUser.ID
} }
// [FIX] Pre-check and cleanup colliding identifiers from OTHER users
for _, lid := range loginIDRecords {
var existingID domain.UserLoginID
// Search in DB (including soft-deleted) for any record with the same login_id but DIFFERENT user_id
if err := h.UserRepo.DB().Unscoped().Where("login_id = ? AND user_id != ?", lid.LoginID, localUser.ID).First(&existingID).Error; err == nil {
slog.Info("BulkCreate: Cleaning up colliding identifier from another user", "loginID", lid.LoginID, "oldUserID", existingID.UserID)
_ = h.UserRepo.DB().Unscoped().Where("login_id = ?", lid.LoginID).Delete(&domain.UserLoginID{}).Error
}
}
if err := h.UserRepo.UpdateUserLoginIDs(c.Context(), localUser.ID, loginIDRecords); err != nil { if err := h.UserRepo.UpdateUserLoginIDs(c.Context(), localUser.ID, loginIDRecords); err != nil {
slog.Error("Failed to update user login IDs in bulk", "userID", localUser.ID, "error", err) slog.Error("Failed to update user login IDs in bulk", "userID", localUser.ID, "error", err)
markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err) markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err)
results = append(results, bulkUserResult{
Email: userEmail,
OriginalEmail: emailEvaluation.OriginalEmail,
SuggestedEmail: emailEvaluation.SuggestedEmail,
Status: "blockingError",
Warnings: emailEvaluation.Warnings,
Success: false,
Message: "LoginID sync failed: " + err.Error(),
})
continue
} }
if h.KetoOutboxRepo != nil { if h.KetoOutboxRepo != nil {
@@ -1355,10 +1504,11 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
Email: userEmail, Email: userEmail,
OriginalEmail: emailEvaluation.OriginalEmail, OriginalEmail: emailEvaluation.OriginalEmail,
SuggestedEmail: emailEvaluation.SuggestedEmail, SuggestedEmail: emailEvaluation.SuggestedEmail,
Status: emailEvaluation.Status, Status: resultStatus,
Warnings: emailEvaluation.Warnings, Warnings: emailEvaluation.Warnings,
Success: true, Success: true,
UserID: identityID, UserID: targetLocalID,
ModifiedFields: modifiedFields,
}) })
} }
@@ -1575,7 +1725,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
// Pre-fetch tenant cache if tenantSlug is being changed. // Pre-fetch tenant cache if tenantSlug is being changed.
type tenantCacheItem struct { type tenantCacheItem struct {
ID string ID string
Schema []interface{} Schema []any
} }
tenantCache := make(map[string]tenantCacheItem) tenantCache := make(map[string]tenantCacheItem)
@@ -1913,7 +2063,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if h.TenantService != nil { if h.TenantService != nil {
tenant, err := h.TenantService.GetTenant(c.Context(), key) tenant, err := h.TenantService.GetTenant(c.Context(), key)
if err == nil && tenant != nil { if err == nil && tenant != nil {
if schema, ok := tenant.Config["userSchema"].([]interface{}); ok { if schema, ok := tenant.Config["userSchema"].([]any); ok {
if subMeta, ok := val.(map[string]any); ok { if subMeta, ok := val.(map[string]any); ok {
if err := h.validateMetadataWithAuth(subMeta, schema, isAdmin, false); err != nil { if err := h.validateMetadataWithAuth(subMeta, schema, isAdmin, false); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed for tenant "+tenant.Name+": "+err.Error()) return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed for tenant "+tenant.Name+": "+err.Error())
@@ -1934,7 +2084,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
tenant, _ = h.TenantService.GetTenant(c.Context(), tenantID) tenant, _ = h.TenantService.GetTenant(c.Context(), tenantID)
} }
if tenant != nil { if tenant != nil {
if schema, ok := tenant.Config["userSchema"].([]interface{}); ok { if schema, ok := tenant.Config["userSchema"].([]any); ok {
if err := h.validateMetadataWithAuth(req.Metadata, schema, isAdmin, false); err != nil { if err := h.validateMetadataWithAuth(req.Metadata, schema, isAdmin, false); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed: "+err.Error()) return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed: "+err.Error())
} }
@@ -1946,7 +2096,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
traits := identity.Traits traits := identity.Traits
if traits == nil { if traits == nil {
traits = map[string]interface{}{} traits = map[string]any{}
} }
delete(traits, "hanmacFamily") delete(traits, "hanmacFamily")
delete(traits, "userType") delete(traits, "userType")
@@ -2035,10 +2185,8 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if !coreTraits[k] { if !coreTraits[k] {
// Ensure we are merging maps (tenant namespaces) correctly, not overwriting with slices // Ensure we are merging maps (tenant namespaces) correctly, not overwriting with slices
if incomingMap, ok := v.(map[string]any); ok { if incomingMap, ok := v.(map[string]any); ok {
if existingMap, ok := traits[k].(map[string]interface{}); ok { if existingMap, ok := traits[k].(map[string]any); ok {
for subK, subV := range incomingMap { maps.Copy(existingMap, incomingMap)
existingMap[subK] = subV
}
traits[k] = existingMap traits[k] = existingMap
} else { } else {
traits[k] = incomingMap // New namespace traits[k] = incomingMap // New namespace
@@ -2059,7 +2207,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
allEmails := []string{userEmail} allEmails := []string{userEmail}
if secondaryRaw, exists := traits["sub_email"]; exists { if secondaryRaw, exists := traits["sub_email"]; exists {
if secondaryEmails, ok := secondaryRaw.([]interface{}); ok { if secondaryEmails, ok := secondaryRaw.([]any); ok {
for _, se := range secondaryEmails { for _, se := range secondaryEmails {
if seStr, ok := se.(string); ok { if seStr, ok := se.(string); ok {
allEmails = append(allEmails, seStr) allEmails = append(allEmails, seStr)
@@ -2198,6 +2346,8 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusBadRequest, "user id is required") return errorJSON(c, fiber.StatusBadRequest, "user id is required")
} }
slog.Info("[UserHandler] Attempting to delete user", "userID", userID)
// [New] Check access scope before deletion // [New] Check access scope before deletion
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse) requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
@@ -2228,13 +2378,44 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
} }
} }
// [FIX] Support fixed UUID deletion
// If userID is a fixed UUID, it might not be the Kratos internal ID.
actualKratosID := userID
if h.KratosAdmin != nil {
// 1. Try finding by identifier (which checks external_id if it's a UUID)
id, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), userID)
if err == nil && id != "" {
actualKratosID = id
slog.Info("[UserHandler] Mapped userID to Kratos identity ID", "userID", userID, "actualKratosID", actualKratosID)
} else {
// 2. Fallback: If not found by ID/ExternalID, try finding by EMAIL from local DB
if h.UserRepo != nil {
local, err := h.UserRepo.FindByID(c.Context(), userID)
if err == nil && local != nil && local.Email != "" {
id, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), local.Email)
if err == nil && id != "" {
actualKratosID = id
slog.Info("[UserHandler] Mapped userID to Kratos identity ID via email", "userID", userID, "email", local.Email, "actualKratosID", actualKratosID)
}
}
}
}
}
if err := h.enqueueDeletedUserRelyingPartyCleanup(c.Context(), requester, userID); err != nil { if err := h.enqueueDeletedUserRelyingPartyCleanup(c.Context(), requester, userID); err != nil {
slog.Error("[UserHandler] Failed to enqueue RP cleanup", "userID", userID, "error", err)
return errorJSON(c, fiber.StatusInternalServerError, err.Error()) return errorJSON(c, fiber.StatusInternalServerError, err.Error())
} }
if err := h.KratosAdmin.DeleteIdentity(c.Context(), userID); err != nil { if err := h.KratosAdmin.DeleteIdentity(c.Context(), actualKratosID); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error()) slog.Error("[UserHandler] Failed to delete Kratos identity", "userID", userID, "actualKratosID", actualKratosID, "error", err)
// If Kratos says 404, it might already be gone, so we can proceed to cleanup local DB
if !strings.Contains(err.Error(), "404") {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
} }
slog.Info("[UserHandler] Successfully deleted Kratos identity", "userID", userID, "actualKratosID", actualKratosID)
if h.Worksmobile != nil && identity != nil { if h.Worksmobile != nil && identity != nil {
localUser := h.mapToLocalUser(*identity) localUser := h.mapToLocalUser(*identity)
if err := h.Worksmobile.EnqueueUserDeleteIfInScope(c.Context(), *localUser); err != nil { if err := h.Worksmobile.EnqueueUserDeleteIfInScope(c.Context(), *localUser); err != nil {
@@ -2259,6 +2440,8 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
if err := h.UserRepo.Delete(context.Background(), userID); err != nil { if err := h.UserRepo.Delete(context.Background(), userID); err != nil {
slog.Error("[UserHandler] Failed to delete local user read-model", "userID", userID, "error", err) slog.Error("[UserHandler] Failed to delete local user read-model", "userID", userID, "error", err)
markUserProjectionFailed(context.Background(), h.UserProjectionRepo, err) markUserProjectionFailed(context.Background(), h.UserProjectionRepo, err)
} else {
slog.Info("[UserHandler] Successfully deleted local user read-model", "userID", userID)
} }
} }
@@ -2387,7 +2570,7 @@ func (h *UserHandler) listDeletedUserRelyingPartyRelations(ctx context.Context,
var tuples []service.RelationTuple var tuples []service.RelationTuple
var err error var err error
for attempt := 0; attempt < 3; attempt++ { for attempt := range 3 {
tuples, err = h.KetoService.ListRelations(ctx, "RelyingParty", "", "", subject) tuples, err = h.KetoService.ListRelations(ctx, "RelyingParty", "", "", subject)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -2427,6 +2610,21 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
traits := identity.Traits traits := identity.Traits
role := roleFromTraits(traits) role := roleFromTraits(traits)
// [FIX] Prioritize Local DB ID (the fixed UUID from user)
finalID := identity.ID
email := extractTraitString(traits, "email")
if h.UserRepo != nil && email != "" {
// 1. Try finding by email first as it's a strong identifier
if local, err := h.UserRepo.FindByEmail(ctx, email); err == nil && local != nil {
finalID = local.ID
} else if identity.ExternalID != "" {
// 2. Try finding by ID directly (in case ID was fixed to ExternalID)
if local, err := h.UserRepo.FindByID(ctx, identity.ExternalID); err == nil && local != nil {
finalID = local.ID
}
}
}
tenantID := extractTraitString(traits, "tenant_id") tenantID := extractTraitString(traits, "tenant_id")
tenantSlug := "" tenantSlug := ""
var tenantSummary *domain.Tenant var tenantSummary *domain.Tenant
@@ -2440,7 +2638,7 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
var customLoginIDs []string var customLoginIDs []string
if raw, ok := traits["custom_login_ids"]; ok { if raw, ok := traits["custom_login_ids"]; ok {
if ids, ok := raw.([]interface{}); ok { if ids, ok := raw.([]any); ok {
for _, id := range ids { for _, id := range ids {
if s, ok := id.(string); ok { if s, ok := id.(string); ok {
customLoginIDs = append(customLoginIDs, s) customLoginIDs = append(customLoginIDs, s)
@@ -2452,7 +2650,7 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
} }
summary := userSummary{ summary := userSummary{
ID: identity.ID, ID: finalID,
Email: extractTraitString(traits, "email"), Email: extractTraitString(traits, "email"),
LoginID: resolvePasswordLoginID(traits), LoginID: resolvePasswordLoginID(traits),
CustomLoginIDs: customLoginIDs, CustomLoginIDs: customLoginIDs,
@@ -2512,8 +2710,15 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
traits := identity.Traits traits := identity.Traits
role := roleFromTraits(traits) role := roleFromTraits(traits)
// [FIX] Prioritize ExternalID as the primary ID for Baron SSO if it exists.
// This ensures that admin-provided UUIDs are kept consistent across async syncs.
finalID := identity.ID
if identity.ExternalID != "" {
finalID = identity.ExternalID
}
user := &domain.User{ user := &domain.User{
ID: identity.ID, ID: finalID,
Email: extractTraitString(traits, "email"), Email: extractTraitString(traits, "email"),
Name: extractTraitString(traits, "name"), Name: extractTraitString(traits, "name"),
Phone: extractTraitString(traits, "phone_number"), Phone: extractTraitString(traits, "phone_number"),
@@ -2637,7 +2842,7 @@ func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole
} }
} }
func extractTraitString(traits map[string]interface{}, key string) string { func extractTraitString(traits map[string]any, key string) string {
if traits == nil { if traits == nil {
return "" return ""
} }
@@ -2649,12 +2854,12 @@ func extractTraitString(traits map[string]interface{}, key string) string {
return "" return ""
} }
func extractTraitStringArray(traits map[string]interface{}, key string) []string { func extractTraitStringArray(traits map[string]any, key string) []string {
if traits == nil { if traits == nil {
return nil return nil
} }
if raw, ok := traits[key]; ok { if raw, ok := traits[key]; ok {
if slice, ok := raw.([]interface{}); ok { if slice, ok := raw.([]any); ok {
var result []string var result []string
for _, v := range slice { for _, v := range slice {
if s, ok := v.(string); ok { if s, ok := v.(string); ok {
@@ -2670,10 +2875,10 @@ func extractTraitStringArray(traits map[string]interface{}, key string) []string
return nil return nil
} }
func resolvePasswordLoginID(traits map[string]interface{}) string { func resolvePasswordLoginID(traits map[string]any) string {
// First check custom_login_ids (array) // First check custom_login_ids (array)
if raw, ok := traits["custom_login_ids"]; ok { if raw, ok := traits["custom_login_ids"]; ok {
if ids, ok := raw.([]interface{}); ok && len(ids) > 0 { if ids, ok := raw.([]any); ok && len(ids) > 0 {
if first, ok := ids[0].(string); ok { if first, ok := ids[0].(string); ok {
return first return first
} }
@@ -2693,7 +2898,7 @@ func resolvePasswordLoginID(traits map[string]interface{}) string {
// syncCustomLoginIDs collects all fields marked as isLoginId: true from tenant schemas // syncCustomLoginIDs collects all fields marked as isLoginId: true from tenant schemas
// and populates traits["custom_login_ids"] and returns domain.UserLoginID records for DB. // and populates traits["custom_login_ids"] and returns domain.UserLoginID records for DB.
func syncCustomLoginIDs(ctx context.Context, tenantService service.TenantService, traits map[string]interface{}, metadata map[string]any, userID string) []domain.UserLoginID { func syncCustomLoginIDs(ctx context.Context, tenantService service.TenantService, traits map[string]any, metadata map[string]any, userID string) []domain.UserLoginID {
if tenantService == nil { if tenantService == nil {
return nil return nil
} }
@@ -2726,13 +2931,13 @@ func syncCustomLoginIDs(ctx context.Context, tenantService service.TenantService
continue continue
} }
schema, ok := tenant.Config["userSchema"].([]interface{}) schema, ok := tenant.Config["userSchema"].([]any)
if !ok { if !ok {
continue continue
} }
for _, fieldRaw := range schema { for _, fieldRaw := range schema {
field, ok := fieldRaw.(map[string]interface{}) field, ok := fieldRaw.(map[string]any)
if !ok { if !ok {
continue continue
} }
@@ -2751,7 +2956,7 @@ func syncCustomLoginIDs(ctx context.Context, tenantService service.TenantService
var val string var val string
if namespaced, ok := metadata[tid].(map[string]any); ok { if namespaced, ok := metadata[tid].(map[string]any); ok {
val, _ = namespaced[fieldKey].(string) val, _ = namespaced[fieldKey].(string)
} else if namespaced, ok := metadata[tid].(map[string]interface{}); ok { } else if namespaced, ok := metadata[tid].(map[string]any); ok {
val, _ = namespaced[fieldKey].(string) val, _ = namespaced[fieldKey].(string)
} }
@@ -2761,7 +2966,7 @@ func syncCustomLoginIDs(ctx context.Context, tenantService service.TenantService
if val == "" { if val == "" {
// Check existing trait (namespaced) // Check existing trait (namespaced)
if namespaced, ok := traits[tid].(map[string]interface{}); ok { if namespaced, ok := traits[tid].(map[string]any); ok {
val, _ = namespaced[fieldKey].(string) val, _ = namespaced[fieldKey].(string)
} else if namespaced, ok := traits[tid].(map[string]any); ok { } else if namespaced, ok := traits[tid].(map[string]any); ok {
val, _ = namespaced[fieldKey].(string) val, _ = namespaced[fieldKey].(string)
@@ -2833,13 +3038,13 @@ func isMetadataMap(value any) bool {
if _, ok := value.(map[string]any); ok { if _, ok := value.(map[string]any); ok {
return true return true
} }
if _, ok := value.(map[string]interface{}); ok { if _, ok := value.(map[string]any); ok {
return true return true
} }
return false return false
} }
func normalizeCustomLoginIDsTrait(traits map[string]interface{}) { func normalizeCustomLoginIDsTrait(traits map[string]any) {
raw, exists := traits["custom_login_ids"] raw, exists := traits["custom_login_ids"]
if !exists { if !exists {
return return
@@ -2847,7 +3052,7 @@ func normalizeCustomLoginIDsTrait(traits map[string]interface{}) {
switch values := raw.(type) { switch values := raw.(type) {
case []string: case []string:
return return
case []interface{}: case []any:
normalized := make([]string, 0, len(values)) normalized := make([]string, 0, len(values))
for _, value := range values { for _, value := range values {
if text, ok := value.(string); ok && strings.TrimSpace(text) != "" { if text, ok := value.(string); ok && strings.TrimSpace(text) != "" {
@@ -2909,14 +3114,14 @@ func normalizePhoneNumber(phone string) string {
return normalized return normalized
} }
func (h *UserHandler) validateMetadata(metadata map[string]any, schema []interface{}, checkRequired bool) error { func (h *UserHandler) validateMetadata(metadata map[string]any, schema []any, checkRequired bool) error {
return h.validateMetadataWithAuth(metadata, schema, true, checkRequired) return h.validateMetadataWithAuth(metadata, schema, true, checkRequired)
} }
func (h *UserHandler) validateMetadataWithAuth(metadata map[string]any, schema []interface{}, isAdmin bool, checkRequired bool) error { func (h *UserHandler) validateMetadataWithAuth(metadata map[string]any, schema []any, isAdmin bool, checkRequired bool) error {
schemaMap := make(map[string]map[string]interface{}) schemaMap := make(map[string]map[string]any)
for _, s := range schema { for _, s := range schema {
if m, ok := s.(map[string]interface{}); ok { if m, ok := s.(map[string]any); ok {
if key, ok := m["key"].(string); ok { if key, ok := m["key"].(string); ok {
schemaMap[key] = m schemaMap[key] = m
} }

View File

@@ -45,7 +45,7 @@ func (m *MockKratosAdmin) GetIdentity(ctx context.Context, id string) (*service.
return args.Get(0).(*service.KratosIdentity), args.Error(1) return args.Get(0).(*service.KratosIdentity), args.Error(1)
} }
func (m *MockKratosAdmin) UpdateIdentity(ctx context.Context, id string, traits map[string]interface{}, state string) (*service.KratosIdentity, error) { func (m *MockKratosAdmin) UpdateIdentity(ctx context.Context, id string, traits map[string]any, state string) (*service.KratosIdentity, error) {
args := m.Called(ctx, id, traits, state) args := m.Called(ctx, id, traits, state)
if args.Get(0) == nil { if args.Get(0) == nil {
return nil, args.Error(1) return nil, args.Error(1)
@@ -445,8 +445,8 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
ID: "t-123", ID: "t-123",
Slug: "test-tenant", Slug: "test-tenant",
Config: domain.JSONMap{ Config: domain.JSONMap{
"userSchema": []interface{}{ "userSchema": []any{
map[string]interface{}{"key": "emp_id", "label": "EmpID", "required": true, "isLoginId": true}, map[string]any{"key": "emp_id", "label": "EmpID", "required": true, "isLoginId": true},
}, },
}, },
}, nil).Once() }, nil).Once()
@@ -455,28 +455,32 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
ID: "t-123", ID: "t-123",
Slug: "test-tenant", Slug: "test-tenant",
Config: domain.JSONMap{ Config: domain.JSONMap{
"userSchema": []interface{}{ "userSchema": []any{
map[string]interface{}{"key": "emp_id", "label": "EmpID", "required": true, "isLoginId": true}, map[string]any{"key": "emp_id", "label": "EmpID", "required": true, "isLoginId": true},
}, },
}, },
}, nil) }, nil)
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil) mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
// [FIX] Search-first diagnostic calls
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, mock.Anything).Return("", nil).Maybe()
mockOry.On("CreateUser", mock.Anything, mock.Anything).Return("u-1", nil).Twice() mockOry.On("CreateUser", mock.Anything, mock.Anything).Return("u-1", nil).Twice()
payload := map[string]interface{}{ payload := map[string]any{
"users": []map[string]interface{}{ "users": []map[string]any{
{ {
"email": "user1@test.com", "email": "user1@test.com",
"name": "User One", "name": "User One",
"tenantSlug": "test-tenant", "tenantSlug": "test-tenant",
"metadata": map[string]interface{}{"emp_id": "E001"}, "metadata": map[string]any{"emp_id": "E001"},
}, },
{ {
"email": "user2@test.com", "email": "user2@test.com",
"name": "User Two", "name": "User Two",
"tenantSlug": "test-tenant", "tenantSlug": "test-tenant",
"metadata": map[string]interface{}{"emp_id": "E002"}, "metadata": map[string]any{"emp_id": "E002"},
}, },
}, },
} }
@@ -487,20 +491,20 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
resp, _ := app.Test(req) resp, _ := app.Test(req)
assert.Equal(t, 200, resp.StatusCode) assert.Equal(t, 200, resp.StatusCode)
var result map[string]interface{} var result map[string]any
json.NewDecoder(resp.Body).Decode(&result) json.NewDecoder(resp.Body).Decode(&result)
results := result["results"].([]interface{}) results := result["results"].([]any)
assert.Len(t, results, 2) assert.Len(t, results, 2)
assert.True(t, results[0].(map[string]interface{})["success"].(bool)) assert.True(t, results[0].(map[string]any)["success"].(bool))
assert.True(t, results[1].(map[string]interface{})["success"].(bool)) assert.True(t, results[1].(map[string]any)["success"].(bool))
}) })
t.Run("Fail - Tenant Not Found", func(t *testing.T) { t.Run("Fail - Tenant Not Found", func(t *testing.T) {
mockTenant.On("GetTenantBySlug", mock.Anything, "wrong-tenant").Return(nil, errors.New("not found")).Once() mockTenant.On("GetTenantBySlug", mock.Anything, "wrong-tenant").Return(nil, errors.New("not found")).Once()
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil) mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
payload := map[string]interface{}{ payload := map[string]any{
"users": []map[string]interface{}{ "users": []map[string]any{
{ {
"email": "fail@test.com", "email": "fail@test.com",
"name": "Fail User", "name": "Fail User",
@@ -513,12 +517,12 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req) resp, _ := app.Test(req)
var result map[string]interface{} var result map[string]any
json.NewDecoder(resp.Body).Decode(&result) json.NewDecoder(resp.Body).Decode(&result)
results := result["results"].([]interface{}) results := result["results"].([]any)
assert.False(t, results[0].(map[string]interface{})["success"].(bool)) assert.False(t, results[0].(map[string]any)["success"].(bool))
assert.Contains(t, results[0].(map[string]interface{})["message"].(string), "tenant not found") assert.Contains(t, results[0].(map[string]any)["message"].(string), "tenant not found")
}) })
t.Run("Fail - Schema Validation (Required)", func(t *testing.T) { t.Run("Fail - Schema Validation (Required)", func(t *testing.T) {
@@ -526,8 +530,8 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
ID: "t-123", ID: "t-123",
Slug: "test-tenant", Slug: "test-tenant",
Config: domain.JSONMap{ Config: domain.JSONMap{
"userSchema": []interface{}{ "userSchema": []any{
map[string]interface{}{"key": "emp_id", "label": "EmpID", "required": true, "isLoginId": true}, map[string]any{"key": "emp_id", "label": "EmpID", "required": true, "isLoginId": true},
}, },
}, },
}, nil).Once() }, nil).Once()
@@ -536,19 +540,19 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
ID: "t-123", ID: "t-123",
Slug: "test-tenant", Slug: "test-tenant",
Config: domain.JSONMap{ Config: domain.JSONMap{
"userSchema": []interface{}{ "userSchema": []any{
map[string]interface{}{"key": "emp_id", "label": "EmpID", "required": true, "isLoginId": true}, map[string]any{"key": "emp_id", "label": "EmpID", "required": true, "isLoginId": true},
}, },
}, },
}, nil) }, nil)
payload := map[string]interface{}{ payload := map[string]any{
"users": []map[string]interface{}{ "users": []map[string]any{
{ {
"email": "missing-meta@test.com", "email": "missing-meta@test.com",
"name": "No Meta", "name": "No Meta",
"tenantSlug": "test-tenant", "tenantSlug": "test-tenant",
"metadata": map[string]interface{}{}, // emp_id missing "metadata": map[string]any{}, // emp_id missing
}, },
}, },
} }
@@ -557,12 +561,12 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req) resp, _ := app.Test(req)
var result map[string]interface{} var result map[string]any
json.NewDecoder(resp.Body).Decode(&result) json.NewDecoder(resp.Body).Decode(&result)
results := result["results"].([]interface{}) results := result["results"].([]any)
assert.False(t, results[0].(map[string]interface{})["success"].(bool)) assert.False(t, results[0].(map[string]any)["success"].(bool))
assert.Contains(t, results[0].(map[string]interface{})["message"].(string), "field emp_id is required") assert.Contains(t, results[0].(map[string]any)["message"].(string), "field emp_id is required")
}) })
t.Run("Fail - Schema Validation (Regex)", func(t *testing.T) { t.Run("Fail - Schema Validation (Regex)", func(t *testing.T) {
@@ -570,19 +574,19 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
ID: "t-123", ID: "t-123",
Slug: "test-tenant", Slug: "test-tenant",
Config: domain.JSONMap{ Config: domain.JSONMap{
"userSchema": []interface{}{ "userSchema": []any{
map[string]interface{}{"key": "emp_id", "validation": "^E[0-9]{3}$"}, map[string]any{"key": "emp_id", "validation": "^E[0-9]{3}$"},
}, },
}, },
}, nil).Once() }, nil).Once()
payload := map[string]interface{}{ payload := map[string]any{
"users": []map[string]interface{}{ "users": []map[string]any{
{ {
"email": "regex-fail@test.com", "email": "regex-fail@test.com",
"name": "Regex Fail", "name": "Regex Fail",
"tenantSlug": "test-tenant", "tenantSlug": "test-tenant",
"metadata": map[string]interface{}{"emp_id": "abc"}, // Should start with E and 3 digits "metadata": map[string]any{"emp_id": "abc"}, // Should start with E and 3 digits
}, },
}, },
} }
@@ -591,12 +595,12 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req) resp, _ := app.Test(req)
var result map[string]interface{} var result map[string]any
json.NewDecoder(resp.Body).Decode(&result) json.NewDecoder(resp.Body).Decode(&result)
results := result["results"].([]interface{}) results := result["results"].([]any)
assert.False(t, results[0].(map[string]interface{})["success"].(bool)) assert.False(t, results[0].(map[string]any)["success"].(bool))
assert.Contains(t, results[0].(map[string]interface{})["message"].(string), "match validation pattern") assert.Contains(t, results[0].(map[string]any)["message"].(string), "match validation pattern")
}) })
} }
@@ -650,20 +654,20 @@ func TestUserHandler_BulkCreateUsers_ResolvesAdditionalAppointment(t *testing.T)
metadata["employee_id"] == "EMP002" metadata["employee_id"] == "EMP002"
}), mock.Anything).Return("u-appointment", nil).Once() }), mock.Anything).Return("u-appointment", nil).Once()
payload := map[string]interface{}{ payload := map[string]any{
"users": []map[string]interface{}{ "users": []map[string]any{
{ {
"email": "dual@test.com", "email": "dual@test.com",
"name": "Dual User", "name": "Dual User",
"tenantSlug": "test-tenant", "tenantSlug": "test-tenant",
"metadata": map[string]interface{}{"employee_id": "EMP001"}, "metadata": map[string]any{"employee_id": "EMP001"},
"additionalAppointments": []map[string]interface{}{ "additionalAppointments": []map[string]any{
{ {
"tenantSlug": "second-tenant", "tenantSlug": "second-tenant",
"department": "센터", "department": "센터",
"grade": "수석", "grade": "수석",
"jobTitle": "Architecture", "jobTitle": "Architecture",
"metadata": map[string]interface{}{"employee_id": "EMP002"}, "metadata": map[string]any{"employee_id": "EMP002"},
}, },
}, },
}, },
@@ -734,8 +738,8 @@ func TestUserHandler_BulkCreateUsers_AppendsEmailDomainTenantAtLowestPriority(t
appointment["sourceDomain"] == "samaneng.com" appointment["sourceDomain"] == "samaneng.com"
}), mock.Anything).Return("u-domain-assigned", nil).Once() }), mock.Anything).Return("u-domain-assigned", nil).Once()
payload := map[string]interface{}{ payload := map[string]any{
"users": []map[string]interface{}{ "users": []map[string]any{
{ {
"email": "user@samaneng.com", "email": "user@samaneng.com",
"name": "Domain User", "name": "Domain User",
@@ -750,10 +754,10 @@ func TestUserHandler_BulkCreateUsers_AppendsEmailDomainTenantAtLowestPriority(t
resp, _ := app.Test(req) resp, _ := app.Test(req)
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{} var result map[string]any
json.NewDecoder(resp.Body).Decode(&result) json.NewDecoder(resp.Body).Decode(&result)
results := result["results"].([]interface{}) results := result["results"].([]any)
assert.True(t, results[0].(map[string]interface{})["success"].(bool)) assert.True(t, results[0].(map[string]any)["success"].(bool))
mockTenant.AssertExpectations(t) mockTenant.AssertExpectations(t)
mockOry.AssertExpectations(t) mockOry.AssertExpectations(t)
} }
@@ -785,8 +789,8 @@ func TestUserHandler_BulkCreateUsers_UsesEmailDomainTenantAsPrimaryWhenExplicitT
user.Attributes["additionalAppointments"] == nil user.Attributes["additionalAppointments"] == nil
}), mock.Anything).Return("u-domain-primary", nil).Once() }), mock.Anything).Return("u-domain-primary", nil).Once()
payload := map[string]interface{}{ payload := map[string]any{
"users": []map[string]interface{}{ "users": []map[string]any{
{ {
"email": "user@samaneng.com", "email": "user@samaneng.com",
"name": "Domain Primary User", "name": "Domain Primary User",
@@ -800,10 +804,10 @@ func TestUserHandler_BulkCreateUsers_UsesEmailDomainTenantAsPrimaryWhenExplicitT
resp, _ := app.Test(req) resp, _ := app.Test(req)
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{} var result map[string]any
json.NewDecoder(resp.Body).Decode(&result) json.NewDecoder(resp.Body).Decode(&result)
results := result["results"].([]interface{}) results := result["results"].([]any)
assert.True(t, results[0].(map[string]interface{})["success"].(bool)) assert.True(t, results[0].(map[string]any)["success"].(bool))
mockTenant.AssertExpectations(t) mockTenant.AssertExpectations(t)
mockOry.AssertExpectations(t) mockOry.AssertExpectations(t)
} }
@@ -853,9 +857,9 @@ func TestUserHandler_ListUsersReturnsNextCursorWhenMoreRowsExist(t *testing.T) {
app.Get("/users", h.ListUsers) app.Get("/users", h.ListUsers)
mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{ mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{
{ID: "u-3", State: "active", CreatedAt: createdAt, Traits: map[string]interface{}{"email": "c@example.com", "name": "C"}}, {ID: "u-3", State: "active", CreatedAt: createdAt, Traits: map[string]any{"email": "c@example.com", "name": "C"}},
{ID: "u-2", State: "active", CreatedAt: createdAt.Add(-time.Minute), Traits: map[string]interface{}{"email": "b@example.com", "name": "B"}}, {ID: "u-2", State: "active", CreatedAt: createdAt.Add(-time.Minute), Traits: map[string]any{"email": "b@example.com", "name": "B"}},
{ID: "u-1", State: "active", CreatedAt: createdAt.Add(-2 * time.Minute), Traits: map[string]interface{}{"email": "a@example.com", "name": "A"}}, {ID: "u-1", State: "active", CreatedAt: createdAt.Add(-2 * time.Minute), Traits: map[string]any{"email": "a@example.com", "name": "A"}},
}, nil).Once() }, nil).Once()
req := httptest.NewRequest("GET", "/users?limit=2", nil) req := httptest.NewRequest("GET", "/users?limit=2", nil)
@@ -915,8 +919,8 @@ func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) {
return user.Email == "cyhan2@hanmaceng.co.kr" return user.Email == "cyhan2@hanmaceng.co.kr"
}), mock.Anything).Return("u-hanmac", nil).Once() }), mock.Anything).Return("u-hanmac", nil).Once()
payload := map[string]interface{}{ payload := map[string]any{
"users": []map[string]interface{}{ "users": []map[string]any{
{ {
"email": "@hanmaceng.co.kr", "email": "@hanmaceng.co.kr",
"name": "한치영", "name": "한치영",
@@ -931,14 +935,14 @@ func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) {
resp, _ := app.Test(req) resp, _ := app.Test(req)
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{} var result map[string]any
json.NewDecoder(resp.Body).Decode(&result) json.NewDecoder(resp.Body).Decode(&result)
results := result["results"].([]interface{}) results := result["results"].([]any)
row := results[0].(map[string]interface{}) row := results[0].(map[string]any)
assert.True(t, row["success"].(bool)) assert.True(t, row["success"].(bool))
assert.Equal(t, "cyhan2@hanmaceng.co.kr", row["email"]) assert.Equal(t, "cyhan2@hanmaceng.co.kr", row["email"])
assert.Equal(t, "@hanmaceng.co.kr", row["originalEmail"]) assert.Equal(t, "@hanmaceng.co.kr", row["originalEmail"])
assert.Contains(t, row["warnings"].([]interface{}), "suggested") assert.Contains(t, row["warnings"].([]any), "suggested")
}) })
t.Run("full email duplicate local part is blocking error", func(t *testing.T) { t.Run("full email duplicate local part is blocking error", func(t *testing.T) {
@@ -957,8 +961,8 @@ func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) {
mockRepo.On("FindByCompanyCodes", mock.Anything, []string{"hanmac-family", "hanmac"}).Return([]domain.User{}, nil).Once() mockRepo.On("FindByCompanyCodes", mock.Anything, []string{"hanmac-family", "hanmac"}).Return([]domain.User{}, nil).Once()
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil).Once() mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil).Once()
payload := map[string]interface{}{ payload := map[string]any{
"users": []map[string]interface{}{ "users": []map[string]any{
{ {
"email": "han@samaneng.com", "email": "han@samaneng.com",
"name": "한치영", "name": "한치영",
@@ -973,10 +977,10 @@ func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) {
resp, _ := app.Test(req) resp, _ := app.Test(req)
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{} var result map[string]any
json.NewDecoder(resp.Body).Decode(&result) json.NewDecoder(resp.Body).Decode(&result)
results := result["results"].([]interface{}) results := result["results"].([]any)
row := results[0].(map[string]interface{}) row := results[0].(map[string]any)
assert.False(t, row["success"].(bool)) assert.False(t, row["success"].(bool))
assert.Equal(t, "blockingError", row["status"]) assert.Equal(t, "blockingError", row["status"])
assert.Contains(t, row["message"].(string), "한맥가족 내에서 이미 사용 중인 이메일 ID입니다.") assert.Contains(t, row["message"].(string), "한맥가족 내에서 이미 사용 중인 이메일 ID입니다.")
@@ -1017,7 +1021,7 @@ func TestUserHandler_CreateUser_HanmacEmailPolicyBlocksDuplicateLocalPart(t *tes
}, nil).Once() }, nil).Once()
mockRepo.On("FindByCompanyCodes", mock.Anything, []string{"hanmac-family", "hanmac"}).Return([]domain.User{}, nil).Once() mockRepo.On("FindByCompanyCodes", mock.Anything, []string{"hanmac-family", "hanmac"}).Return([]domain.User{}, nil).Once()
payload := map[string]interface{}{ payload := map[string]any{
"email": "han@samaneng.com", "email": "han@samaneng.com",
"name": "한치영", "name": "한치영",
"tenantSlug": "hanmac", "tenantSlug": "hanmac",
@@ -1029,7 +1033,7 @@ func TestUserHandler_CreateUser_HanmacEmailPolicyBlocksDuplicateLocalPart(t *tes
resp, _ := app.Test(req) resp, _ := app.Test(req)
assert.Equal(t, http.StatusConflict, resp.StatusCode) assert.Equal(t, http.StatusConflict, resp.StatusCode)
var result map[string]interface{} var result map[string]any
json.NewDecoder(resp.Body).Decode(&result) json.NewDecoder(resp.Body).Decode(&result)
assert.Contains(t, result["error"].(string), "한맥가족 내에서 이미 사용 중인 이메일 ID입니다.") assert.Contains(t, result["error"].(string), "한맥가족 내에서 이미 사용 중인 이메일 ID입니다.")
mockOry.AssertNotCalled(t, "CreateUser") mockOry.AssertNotCalled(t, "CreateUser")
@@ -1049,12 +1053,12 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) {
t.Run("Success - Update Role and Status", func(t *testing.T) { t.Run("Success - Update Role and Status", func(t *testing.T) {
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{ mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{
ID: "u-1", Traits: map[string]interface{}{"email": "u1@test.com", "tenant_id": "tenant-1"}, State: "active", ID: "u-1", Traits: map[string]any{"email": "u1@test.com", "tenant_id": "tenant-1"}, State: "active",
}, nil).Once() }, nil).Once()
mockKratos.On("UpdateIdentity", mock.Anything, "u-1", mock.Anything, "inactive").Return(&service.KratosIdentity{ mockKratos.On("UpdateIdentity", mock.Anything, "u-1", mock.Anything, "inactive").Return(&service.KratosIdentity{
ID: "u-1", ID: "u-1",
Traits: map[string]interface{}{ Traits: map[string]any{
"email": "u1@test.com", "email": "u1@test.com",
"name": "Bulk User", "name": "Bulk User",
"tenant_id": "tenant-1", "tenant_id": "tenant-1",
@@ -1063,7 +1067,7 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) {
}, nil).Once() }, nil).Once()
status := "inactive" status := "inactive"
payload := map[string]interface{}{ payload := map[string]any{
"userIds": []string{"u-1"}, "userIds": []string{"u-1"},
"status": &status, "status": &status,
} }
@@ -1074,10 +1078,10 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) {
resp, _ := app.Test(req) resp, _ := app.Test(req)
assert.Equal(t, 200, resp.StatusCode) assert.Equal(t, 200, resp.StatusCode)
var result map[string]interface{} var result map[string]any
json.NewDecoder(resp.Body).Decode(&result) json.NewDecoder(resp.Body).Decode(&result)
results := result["results"].([]interface{}) results := result["results"].([]any)
assert.True(t, results[0].(map[string]interface{})["success"].(bool)) assert.True(t, results[0].(map[string]any)["success"].(bool))
assert.Len(t, worksmobile.upserts, 1) assert.Len(t, worksmobile.upserts, 1)
assert.Equal(t, "u-1", worksmobile.upserts[0].ID) assert.Equal(t, "u-1", worksmobile.upserts[0].ID)
assert.Equal(t, domain.UserStatusPreboarding, worksmobile.upserts[0].Status) assert.Equal(t, domain.UserStatusPreboarding, worksmobile.upserts[0].Status)
@@ -1085,7 +1089,7 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) {
t.Run("Fail - Super admin cannot assign tenant or RP admin roles", func(t *testing.T) { t.Run("Fail - Super admin cannot assign tenant or RP admin roles", func(t *testing.T) {
for _, role := range []string{domain.RoleTenantAdmin, domain.RoleRPAdmin} { for _, role := range []string{domain.RoleTenantAdmin, domain.RoleRPAdmin} {
payload := map[string]interface{}{ payload := map[string]any{
"userIds": []string{"u-1"}, "userIds": []string{"u-1"},
"role": role, "role": role,
} }
@@ -1107,7 +1111,7 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) {
}) })
role := domain.RoleSuperAdmin role := domain.RoleSuperAdmin
payload := map[string]interface{}{ payload := map[string]any{
"userIds": []string{"u-1"}, "userIds": []string{"u-1"},
"role": &role, "role": &role,
} }
@@ -1137,7 +1141,7 @@ func TestUserHandler_BulkDeleteUsers(t *testing.T) {
mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once() mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once()
mockKratos.On("DeleteIdentity", mock.Anything, "u-2").Return(nil).Once() mockKratos.On("DeleteIdentity", mock.Anything, "u-2").Return(nil).Once()
payload := map[string]interface{}{ payload := map[string]any{
"userIds": []string{"u-1", "u-2"}, "userIds": []string{"u-1", "u-2"},
} }
body, _ := json.Marshal(payload) body, _ := json.Marshal(payload)
@@ -1182,6 +1186,10 @@ func TestUserHandler_DeleteUserDeletesLocalReadModel(t *testing.T) {
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool { mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
return entry.Namespace == "System" && entry.Object == "global" && entry.Relation == "super_admins" && entry.Subject == "User:u-1" && entry.Action == domain.KetoOutboxActionDelete return entry.Namespace == "System" && entry.Object == "global" && entry.Relation == "super_admins" && entry.Subject == "User:u-1" && entry.Action == domain.KetoOutboxActionDelete
})).Return(nil).Once() })).Return(nil).Once()
// [FIX] Diagnostic call for fixed UUID mapping
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "u-1").Return("", nil).Maybe()
mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once() mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once()
req := httptest.NewRequest(http.MethodDelete, "/users/u-1", nil) req := httptest.NewRequest(http.MethodDelete, "/users/u-1", nil)
@@ -1220,7 +1228,7 @@ func TestUserHandler_BulkDeleteUsers_CleansUpRelyingPartyRelations(t *testing.T)
})).Return(nil).Once() })).Return(nil).Once()
mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once() mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once()
payload := map[string]interface{}{ payload := map[string]any{
"userIds": []string{"u-1"}, "userIds": []string{"u-1"},
} }
body, _ := json.Marshal(payload) body, _ := json.Marshal(payload)
@@ -1281,6 +1289,10 @@ func TestUserHandler_DeleteUserFallsBackToKetoOutboxWhenLiveRelationsAreEmpty(t
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool { mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
return entry.Namespace == "System" && entry.Object == "global" && entry.Relation == "super_admins" && entry.Subject == "User:u-1" && entry.Action == domain.KetoOutboxActionDelete return entry.Namespace == "System" && entry.Object == "global" && entry.Relation == "super_admins" && entry.Subject == "User:u-1" && entry.Action == domain.KetoOutboxActionDelete
})).Return(nil).Once() })).Return(nil).Once()
// [FIX] Diagnostic call for fixed UUID mapping
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "u-1").Return("", nil).Maybe()
mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once() mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once()
req := httptest.NewRequest(http.MethodDelete, "/users/u-1", nil) req := httptest.NewRequest(http.MethodDelete, "/users/u-1", nil)
@@ -1323,6 +1335,10 @@ func TestUserHandler_DeleteUserRecordsCascadeRelyingPartyCleanupAudit(t *testing
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool { mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
return entry.Namespace == "System" && entry.Object == "global" && entry.Relation == "super_admins" && entry.Subject == "User:u-1" && entry.Action == domain.KetoOutboxActionDelete return entry.Namespace == "System" && entry.Object == "global" && entry.Relation == "super_admins" && entry.Subject == "User:u-1" && entry.Action == domain.KetoOutboxActionDelete
})).Return(nil).Once() })).Return(nil).Once()
// [FIX] Diagnostic call for fixed UUID mapping
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "u-1").Return("", nil).Maybe()
mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once() mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once()
req := httptest.NewRequest(http.MethodDelete, "/users/u-1", nil) req := httptest.NewRequest(http.MethodDelete, "/users/u-1", nil)
@@ -1373,21 +1389,21 @@ func TestUserHandler_UpdateUser_AdminOnlyField(t *testing.T) {
tenantID := "t-123" tenantID := "t-123"
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{ mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{
ID: "u-1", ID: "u-1",
Traits: map[string]interface{}{"email": "user@test.com", "tenant_id": tenantID}, Traits: map[string]any{"email": "user@test.com", "tenant_id": tenantID},
}, nil) }, nil)
mockTenant.On("GetTenant", mock.Anything, tenantID).Return(&domain.Tenant{ mockTenant.On("GetTenant", mock.Anything, tenantID).Return(&domain.Tenant{
ID: tenantID, ID: tenantID,
Slug: "test-tenant", Slug: "test-tenant",
Config: domain.JSONMap{ Config: domain.JSONMap{
"userSchema": []interface{}{ "userSchema": []any{
map[string]interface{}{"key": "salary", "adminOnly": true}, map[string]any{"key": "salary", "adminOnly": true},
}, },
}, },
}, nil) }, nil)
payload := map[string]interface{}{ payload := map[string]any{
"metadata": map[string]interface{}{"salary": 5000}, "metadata": map[string]any{"salary": 5000},
} }
body, _ := json.Marshal(payload) body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/users/u-1", bytes.NewReader(body)) req := httptest.NewRequest("PUT", "/users/u-1", bytes.NewReader(body))
@@ -1396,7 +1412,7 @@ func TestUserHandler_UpdateUser_AdminOnlyField(t *testing.T) {
resp, _ := app.Test(req) resp, _ := app.Test(req)
assert.Equal(t, 400, resp.StatusCode) // validation failed assert.Equal(t, 400, resp.StatusCode) // validation failed
var result map[string]interface{} var result map[string]any
json.NewDecoder(resp.Body).Decode(&result) json.NewDecoder(resp.Body).Decode(&result)
assert.Contains(t, result["error"].(string), "field salary is admin only") assert.Contains(t, result["error"].(string), "field salary is admin only")
}) })
@@ -1414,11 +1430,11 @@ func TestUserHandler_UpdateUser_RejectsDeprecatedAdminRoles(t *testing.T) {
for _, role := range []string{domain.RoleTenantAdmin, domain.RoleRPAdmin} { for _, role := range []string{domain.RoleTenantAdmin, domain.RoleRPAdmin} {
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{ mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{
ID: "u-1", ID: "u-1",
Traits: map[string]interface{}{"email": "user@test.com", "role": domain.RoleUser}, Traits: map[string]any{"email": "user@test.com", "role": domain.RoleUser},
State: "active", State: "active",
}, nil).Once() }, nil).Once()
payload := map[string]interface{}{"role": role} payload := map[string]any{"role": role}
body, _ := json.Marshal(payload) body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/users/u-1", bytes.NewReader(body)) req := httptest.NewRequest("PUT", "/users/u-1", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
@@ -1437,20 +1453,20 @@ func TestSyncCustomLoginIDs_IgnoresFlatMetadataMaps(t *testing.T) {
ID: tenantID, ID: tenantID,
Slug: "test-tenant", Slug: "test-tenant",
Config: domain.JSONMap{ Config: domain.JSONMap{
"userSchema": []interface{}{ "userSchema": []any{
map[string]interface{}{"key": "emp_no", "isLoginId": true}, map[string]any{"key": "emp_no", "isLoginId": true},
}, },
}, },
}, nil).Once() }, nil).Once()
traits := map[string]interface{}{ traits := map[string]any{
"tenant_id": tenantID, "tenant_id": tenantID,
} }
metadata := map[string]any{ metadata := map[string]any{
tenantID: map[string]interface{}{ tenantID: map[string]any{
"emp_no": "E1001", "emp_no": "E1001",
}, },
"worksmobileAliasEmails": map[string]interface{}{ "worksmobileAliasEmails": map[string]any{
"0": "alias@hanmaceng.co.kr", "0": "alias@hanmaceng.co.kr",
}, },
} }
@@ -1482,7 +1498,7 @@ func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
userID := "u-1" userID := "u-1"
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{ mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
ID: userID, ID: userID,
Traits: map[string]interface{}{ Traits: map[string]any{
"email": "user@test.com", "email": "user@test.com",
"companyCode": "test-tenant", "companyCode": "test-tenant",
"tenant_id": tenantID, "tenant_id": tenantID,
@@ -1493,8 +1509,8 @@ func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
ID: tenantID, ID: tenantID,
Slug: "test-tenant", Slug: "test-tenant",
Config: domain.JSONMap{ Config: domain.JSONMap{
"userSchema": []interface{}{ "userSchema": []any{
map[string]interface{}{"key": "emp_no", "label": "Employee No", "isLoginId": true}, map[string]any{"key": "emp_no", "label": "Employee No", "isLoginId": true},
}, },
}, },
}, nil) }, nil)
@@ -1502,8 +1518,8 @@ func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
ID: tenantID, ID: tenantID,
Slug: "test-tenant", Slug: "test-tenant",
Config: domain.JSONMap{ Config: domain.JSONMap{
"userSchema": []interface{}{ "userSchema": []any{
map[string]interface{}{"key": "emp_no", "label": "Employee No", "isLoginId": true}, map[string]any{"key": "emp_no", "label": "Employee No", "isLoginId": true},
}, },
}, },
}, nil) }, nil)
@@ -1511,20 +1527,20 @@ func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once() mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once()
// Expect traits to include 'custom_login_ids' synced from 'emp_no' // Expect traits to include 'custom_login_ids' synced from 'emp_no'
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool { mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]any) bool {
ids, ok := traits["custom_login_ids"].([]string) ids, ok := traits["custom_login_ids"].([]string)
return ok && len(ids) > 0 && ids[0] == "E1001" return ok && len(ids) > 0 && ids[0] == "E1001"
}), mock.Anything).Return(&service.KratosIdentity{ }), mock.Anything).Return(&service.KratosIdentity{
ID: userID, ID: userID,
Traits: map[string]interface{}{ Traits: map[string]any{
"custom_login_ids": []interface{}{"E1001"}, "custom_login_ids": []any{"E1001"},
"email": "user@test.com", "email": "user@test.com",
}, },
}, nil).Once() }, nil).Once()
payload := map[string]interface{}{ payload := map[string]any{
"metadata": map[string]interface{}{ "metadata": map[string]any{
tenantID: map[string]interface{}{ tenantID: map[string]any{
"emp_no": "E1001", "emp_no": "E1001",
}, },
}, },
@@ -1555,12 +1571,12 @@ func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
userID := "u-2" userID := "u-2"
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{ mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
ID: userID, ID: userID,
Traits: map[string]interface{}{ Traits: map[string]any{
"email": "user2@test.com", "email": "user2@test.com",
"companyCode": "test-tenant", "companyCode": "test-tenant",
"tenant_id": tenantID, "tenant_id": tenantID,
"id": "old-id", "id": "old-id",
tenantID: map[string]interface{}{ tenantID: map[string]any{
"emp_no": "E2002", "emp_no": "E2002",
}, },
}, },
@@ -1570,8 +1586,8 @@ func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
ID: tenantID, ID: tenantID,
Slug: "test-tenant", Slug: "test-tenant",
Config: domain.JSONMap{ Config: domain.JSONMap{
"userSchema": []interface{}{ "userSchema": []any{
map[string]interface{}{"key": "emp_no", "isLoginId": true}, map[string]any{"key": "emp_no", "isLoginId": true},
}, },
}, },
}, nil) }, nil)
@@ -1579,8 +1595,8 @@ func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
ID: tenantID, ID: tenantID,
Slug: "test-tenant", Slug: "test-tenant",
Config: domain.JSONMap{ Config: domain.JSONMap{
"userSchema": []interface{}{ "userSchema": []any{
map[string]interface{}{"key": "emp_no", "isLoginId": true}, map[string]any{"key": "emp_no", "isLoginId": true},
}, },
}, },
}, nil) }, nil)
@@ -1588,17 +1604,17 @@ func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once() mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once()
// Even if metadata is empty, it should sync from existing traits // Even if metadata is empty, it should sync from existing traits
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool { mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]any) bool {
ids, ok := traits["custom_login_ids"].([]string) ids, ok := traits["custom_login_ids"].([]string)
return ok && len(ids) > 0 && ids[0] == "E2002" return ok && len(ids) > 0 && ids[0] == "E2002"
}), mock.Anything).Return(&service.KratosIdentity{ }), mock.Anything).Return(&service.KratosIdentity{
ID: userID, ID: userID,
Traits: map[string]interface{}{ Traits: map[string]any{
"custom_login_ids": []interface{}{"E2002"}, "custom_login_ids": []any{"E2002"},
}, },
}, nil).Once() }, nil).Once()
payload := map[string]interface{}{ payload := map[string]any{
"name": "New Name", "name": "New Name",
} }
body, _ := json.Marshal(payload) body, _ := json.Marshal(payload)
@@ -1630,8 +1646,8 @@ func TestUserHandler_UpdateUser_PasswordUsesProvider(t *testing.T) {
userID := "u-1" userID := "u-1"
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{ mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
ID: userID, ID: userID,
Traits: map[string]interface{}{ Traits: map[string]any{
"custom_login_ids": []interface{}{"dyddus1210"}, "custom_login_ids": []any{"dyddus1210"},
"email": "dyddus1210@gmail.com", "email": "dyddus1210@gmail.com",
"companyCode": "test-tenant", "companyCode": "test-tenant",
"tenant_id": "t-1", "tenant_id": "t-1",
@@ -1643,8 +1659,8 @@ func TestUserHandler_UpdateUser_PasswordUsesProvider(t *testing.T) {
ID: "t-1", ID: "t-1",
Slug: "test-tenant", Slug: "test-tenant",
Config: domain.JSONMap{ Config: domain.JSONMap{
"userSchema": []interface{}{ "userSchema": []any{
map[string]interface{}{"key": "emp_id", "isLoginId": true}, map[string]any{"key": "emp_id", "isLoginId": true},
}, },
}, },
}, nil) }, nil)
@@ -1652,27 +1668,27 @@ func TestUserHandler_UpdateUser_PasswordUsesProvider(t *testing.T) {
ID: "t-1", ID: "t-1",
Slug: "test-tenant", Slug: "test-tenant",
Config: domain.JSONMap{ Config: domain.JSONMap{
"userSchema": []interface{}{ "userSchema": []any{
map[string]interface{}{"key": "emp_id", "isLoginId": true}, map[string]any{"key": "emp_id", "isLoginId": true},
}, },
}, },
}, nil) }, nil)
mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once() mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once()
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool { mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]any) bool {
ids, ok := traits["custom_login_ids"].([]string) ids, ok := traits["custom_login_ids"].([]string)
return ok && len(ids) > 0 && ids[0] == "dyddus1210" return ok && len(ids) > 0 && ids[0] == "dyddus1210"
}), "").Return(&service.KratosIdentity{ }), "").Return(&service.KratosIdentity{
ID: userID, ID: userID,
Traits: map[string]interface{}{ Traits: map[string]any{
"custom_login_ids": []interface{}{"dyddus1210"}, "custom_login_ids": []any{"dyddus1210"},
"email": "dyddus1210@gmail.com", "email": "dyddus1210@gmail.com",
}, },
}, nil).Once() }, nil).Once()
mockOry.On("UpdateUserPassword", "dyddus1210", "asdfzxcv1234!", (*http.Request)(nil)).Return(nil).Once() mockOry.On("UpdateUserPassword", "dyddus1210", "asdfzxcv1234!", (*http.Request)(nil)).Return(nil).Once()
payload := map[string]interface{}{ payload := map[string]any{
"password": "asdfzxcv1234!", "password": "asdfzxcv1234!",
} }
body, _ := json.Marshal(payload) body, _ := json.Marshal(payload)
@@ -1704,7 +1720,7 @@ func TestUserHandler_UpdateUser_PasswordFallsBackToEmail(t *testing.T) {
userID := "u-2" userID := "u-2"
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{ mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
ID: userID, ID: userID,
Traits: map[string]interface{}{ Traits: map[string]any{
"email": "dyddus1210@gmail.com", "email": "dyddus1210@gmail.com",
"companyCode": "test-tenant", "companyCode": "test-tenant",
}, },
@@ -1716,18 +1732,18 @@ func TestUserHandler_UpdateUser_PasswordFallsBackToEmail(t *testing.T) {
}, nil) }, nil)
mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once() mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once()
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool { mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]any) bool {
return traits["email"] == "dyddus1210@gmail.com" return traits["email"] == "dyddus1210@gmail.com"
}), "").Return(&service.KratosIdentity{ }), "").Return(&service.KratosIdentity{
ID: userID, ID: userID,
Traits: map[string]interface{}{ Traits: map[string]any{
"email": "dyddus1210@gmail.com", "email": "dyddus1210@gmail.com",
}, },
}, nil).Once() }, nil).Once()
mockOry.On("UpdateUserPassword", "dyddus1210@gmail.com", "asdfzxcv1234!", (*http.Request)(nil)).Return(nil).Once() mockOry.On("UpdateUserPassword", "dyddus1210@gmail.com", "asdfzxcv1234!", (*http.Request)(nil)).Return(nil).Once()
payload := map[string]interface{}{ payload := map[string]any{
"password": "asdfzxcv1234!", "password": "asdfzxcv1234!",
} }
body, _ := json.Marshal(payload) body, _ := json.Marshal(payload)
@@ -1757,8 +1773,8 @@ func TestUserHandler_CreateUser_LoginIDSync(t *testing.T) {
ID: tenantID, ID: tenantID,
Slug: "test-tenant", Slug: "test-tenant",
Config: domain.JSONMap{ Config: domain.JSONMap{
"userSchema": []interface{}{ "userSchema": []any{
map[string]interface{}{"key": "emp_no", "label": "Employee No", "isLoginId": true}, map[string]any{"key": "emp_no", "label": "Employee No", "isLoginId": true},
}, },
}, },
}, nil) }, nil)
@@ -1766,8 +1782,8 @@ func TestUserHandler_CreateUser_LoginIDSync(t *testing.T) {
ID: tenantID, ID: tenantID,
Slug: "test-tenant", Slug: "test-tenant",
Config: domain.JSONMap{ Config: domain.JSONMap{
"userSchema": []interface{}{ "userSchema": []any{
map[string]interface{}{"key": "emp_no", "label": "Employee No", "isLoginId": true}, map[string]any{"key": "emp_no", "label": "Employee No", "isLoginId": true},
}, },
}, },
}, nil) }, nil)
@@ -1783,8 +1799,8 @@ func TestUserHandler_CreateUser_LoginIDSync(t *testing.T) {
// Mock GetIdentity after creation // Mock GetIdentity after creation
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{ mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{
ID: "u-1", ID: "u-1",
Traits: map[string]interface{}{ Traits: map[string]any{
"custom_login_ids": []interface{}{"E1001"}, "custom_login_ids": []any{"E1001"},
"email": "new@test.com", "email": "new@test.com",
"companyCode": "test-tenant", "companyCode": "test-tenant",
}, },
@@ -1793,12 +1809,12 @@ func TestUserHandler_CreateUser_LoginIDSync(t *testing.T) {
// Mock ListManageableTenants for mapIdentitySummary // Mock ListManageableTenants for mapIdentitySummary
mockTenant.On("ListManageableTenants", mock.Anything, "u-1").Return([]domain.Tenant{}, nil).Once() mockTenant.On("ListManageableTenants", mock.Anything, "u-1").Return([]domain.Tenant{}, nil).Once()
payload := map[string]interface{}{ payload := map[string]any{
"email": "new@test.com", "email": "new@test.com",
"name": "New User", "name": "New User",
"tenantSlug": "test-tenant", "tenantSlug": "test-tenant",
"metadata": map[string]interface{}{ "metadata": map[string]any{
tenantID: map[string]interface{}{ tenantID: map[string]any{
"emp_no": "E1001", "emp_no": "E1001",
}, },
}, },
@@ -1849,25 +1865,25 @@ func TestUserHandler_CreateUser_UsesAdditionalAppointmentAsPrimaryTenant(t *test
}), mock.Anything).Return("u-appointment", nil).Once() }), mock.Anything).Return("u-appointment", nil).Once()
mockKratos.On("GetIdentity", mock.Anything, "u-appointment").Return(&service.KratosIdentity{ mockKratos.On("GetIdentity", mock.Anything, "u-appointment").Return(&service.KratosIdentity{
ID: "u-appointment", ID: "u-appointment",
Traits: map[string]interface{}{ Traits: map[string]any{
"email": "new@samaneng.com", "email": "new@samaneng.com",
"name": "Appointment User", "name": "Appointment User",
"companyCode": "saman", "companyCode": "saman",
"tenant_id": tenantID, "tenant_id": tenantID,
"additionalAppointments": []interface{}{ "additionalAppointments": []any{
map[string]interface{}{"tenantId": tenantID, "tenantSlug": "saman"}, map[string]any{"tenantId": tenantID, "tenantSlug": "saman"},
}, },
}, },
State: "active", State: "active",
}, nil).Once() }, nil).Once()
payload := map[string]interface{}{ payload := map[string]any{
"email": "new@samaneng.com", "email": "new@samaneng.com",
"name": "Appointment User", "name": "Appointment User",
"additionalAppointments": []map[string]interface{}{ "additionalAppointments": []map[string]any{
{"tenantId": tenantID, "tenantSlug": "saman", "tenantName": "삼안"}, {"tenantId": tenantID, "tenantSlug": "saman", "tenantName": "삼안"},
}, },
"metadata": map[string]interface{}{ "metadata": map[string]any{
"userType": "hanmac", "userType": "hanmac",
}, },
} }
@@ -1939,7 +1955,7 @@ func TestUserHandler_CreateUser_AutoCreatesPersonalTenantWhenAssignmentMissing(t
}), mock.Anything).Return("u-personal", nil).Once() }), mock.Anything).Return("u-personal", nil).Once()
mockKratos.On("GetIdentity", mock.Anything, "u-personal").Return(&service.KratosIdentity{ mockKratos.On("GetIdentity", mock.Anything, "u-personal").Return(&service.KratosIdentity{
ID: "u-personal", ID: "u-personal",
Traits: map[string]interface{}{ Traits: map[string]any{
"email": "personal-user@example.com", "email": "personal-user@example.com",
"name": "Personal User", "name": "Personal User",
"companyCode": "personal-01970f0d96667548963d2890351f03dd", "companyCode": "personal-01970f0d96667548963d2890351f03dd",
@@ -1947,7 +1963,7 @@ func TestUserHandler_CreateUser_AutoCreatesPersonalTenantWhenAssignmentMissing(t
}, },
State: "active", State: "active",
}, nil).Once() }, nil).Once()
payload := map[string]interface{}{ payload := map[string]any{
"email": "personal-user@example.com", "email": "personal-user@example.com",
"name": "Personal User", "name": "Personal User",
} }
@@ -1992,7 +2008,7 @@ func TestUserHandler_CreateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{ mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{
ID: "user-id", ID: "user-id",
State: "active", State: "active",
Traits: map[string]interface{}{ Traits: map[string]any{
"email": "user@test.com", "email": "user@test.com",
"name": "Test User", "name": "Test User",
"tenant_id": "tenant-id", "tenant_id": "tenant-id",
@@ -2029,7 +2045,7 @@ func TestUserHandler_UpdateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
identity := &service.KratosIdentity{ identity := &service.KratosIdentity{
ID: "user-id", ID: "user-id",
State: "active", State: "active",
Traits: map[string]interface{}{ Traits: map[string]any{
"email": "user@test.com", "email": "user@test.com",
"name": "Test User", "name": "Test User",
"tenant_id": "old-tenant-id", "tenant_id": "old-tenant-id",
@@ -2046,13 +2062,13 @@ func TestUserHandler_UpdateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
Slug: "new-tenant", Slug: "new-tenant",
Config: domain.JSONMap{}, Config: domain.JSONMap{},
}, nil).Once() }, nil).Once()
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.MatchedBy(func(traits map[string]interface{}) bool { mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.MatchedBy(func(traits map[string]any) bool {
_, hasCompanyCode := traits["companyCode"] _, hasCompanyCode := traits["companyCode"]
return !hasCompanyCode && traits["tenant_id"] == "new-tenant-id" return !hasCompanyCode && traits["tenant_id"] == "new-tenant-id"
}), "").Return(&service.KratosIdentity{ }), "").Return(&service.KratosIdentity{
ID: "user-id", ID: "user-id",
State: "active", State: "active",
Traits: map[string]interface{}{ Traits: map[string]any{
"email": "user@test.com", "email": "user@test.com",
"name": "Test User", "name": "Test User",
"tenant_id": "new-tenant-id", "tenant_id": "new-tenant-id",
@@ -2091,7 +2107,7 @@ func TestUserHandler_BulkUpdateUsersAcceptsTenantSlugAndRejectsCompanyCode(t *te
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{ mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{
ID: "user-id", ID: "user-id",
State: "active", State: "active",
Traits: map[string]interface{}{ Traits: map[string]any{
"email": "user@test.com", "email": "user@test.com",
"name": "Test User", "name": "Test User",
"tenant_id": "old-tenant-id", "tenant_id": "old-tenant-id",
@@ -2102,13 +2118,13 @@ func TestUserHandler_BulkUpdateUsersAcceptsTenantSlugAndRejectsCompanyCode(t *te
ID: "new-tenant-id", ID: "new-tenant-id",
Slug: "new-tenant", Slug: "new-tenant",
}, nil).Once() }, nil).Once()
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.MatchedBy(func(traits map[string]interface{}) bool { mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.MatchedBy(func(traits map[string]any) bool {
_, hasCompanyCode := traits["companyCode"] _, hasCompanyCode := traits["companyCode"]
return !hasCompanyCode && traits["tenant_id"] == "new-tenant-id" return !hasCompanyCode && traits["tenant_id"] == "new-tenant-id"
}), "active").Return(&service.KratosIdentity{ }), "active").Return(&service.KratosIdentity{
ID: "user-id", ID: "user-id",
State: "active", State: "active",
Traits: map[string]interface{}{ Traits: map[string]any{
"email": "user@test.com", "email": "user@test.com",
"name": "Test User", "name": "Test User",
"tenant_id": "new-tenant-id", "tenant_id": "new-tenant-id",
@@ -2137,7 +2153,7 @@ func TestUserHandler_MapToLocalUserKeepsRoleAndGradeSeparate(t *testing.T) {
identity := service.KratosIdentity{ identity := service.KratosIdentity{
ID: "user-grade-id", ID: "user-grade-id",
State: "active", State: "active",
Traits: map[string]interface{}{ Traits: map[string]any{
"email": "grade@example.com", "email": "grade@example.com",
"name": "Grade User", "name": "Grade User",
"role": domain.RoleUser, "role": domain.RoleUser,

View File

@@ -0,0 +1,257 @@
package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"bytes"
"encoding/json"
"fmt"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestUserHandler_BulkCreateUsers_UUIDSupport(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockOry := new(MockOryProvider)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
OryProvider: mockOry,
TenantService: mockTenant,
}
app.Post("/users/bulk", h.BulkCreateUsers)
t.Run("Success - Provided UUID", func(t *testing.T) {
testUuid := "550e8400-e29b-41d4-a716-446655440000"
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
ID: "t-1",
Slug: "test-tenant",
}, nil).Once()
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
// 1. Search-first check: Simulate user NOT found by this UUID
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, testUuid).Return("", nil).Once()
// 2. Create identity
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
return user.ID == testUuid && user.Email == "uuid@test.com"
}), mock.Anything).Return(testUuid, nil).Once()
payload := map[string]any{
"users": []map[string]any{
{
"email": "uuid@test.com",
"name": "UUID User",
"tenantSlug": "test-tenant",
"id": testUuid,
},
},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, 200, resp.StatusCode)
var result struct {
Results []bulkUserResult `json:"results"`
}
json.NewDecoder(resp.Body).Decode(&result)
assert.Len(t, result.Results, 1)
assert.True(t, result.Results[0].Success)
assert.Equal(t, testUuid, result.Results[0].UserID)
})
t.Run("Success - Provided UUID Already Exists (Idempotency)", func(t *testing.T) {
testUuid := "550e8400-e29b-41d4-a716-446655440005"
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
ID: "t-1",
Slug: "test-tenant",
}, nil).Once()
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
// 1. Search-first check: Simulate user FOUND by this UUID
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, testUuid).Return(testUuid, nil).Once()
// 2. Fetch existing identity for field comparison
mockKratos.On("GetIdentity", mock.Anything, testUuid).Return(&service.KratosIdentity{
ID: testUuid,
Traits: map[string]any{
"email": "existing@test.com",
"name": "Old Name",
},
}, nil).Once()
payload := map[string]any{
"users": []map[string]any{
{
"email": "existing@test.com",
"name": "Existing User",
"tenantSlug": "test-tenant",
"id": testUuid,
},
},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, 200, resp.StatusCode)
var result struct {
Results []bulkUserResult `json:"results"`
}
json.NewDecoder(resp.Body).Decode(&result)
assert.Len(t, result.Results, 1)
assert.True(t, result.Results[0].Success)
assert.Equal(t, testUuid, result.Results[0].UserID)
assert.Equal(t, "updated", result.Results[0].Status)
// Should have "Name" in modified fields since we changed from "Old Name" to "Existing User"
assert.Contains(t, result.Results[0].ModifiedFields, "Name")
})
t.Run("Fail - Duplicate UUID Conflict", func(t *testing.T) {
testUuid := "550e8400-e29b-41d4-a716-446655440001"
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
ID: "t-1",
Slug: "test-tenant",
}, nil).Once()
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
// 1. Search-first check: Simulate NOT found by this UUID (initial check)
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, testUuid).Return("", nil).Once()
// 2. Create identity: Simulate Ory returning a 409 conflict for the UUID
conflictErr := fmt.Errorf("ory provider: identity already exists for uuid=%s", testUuid)
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
return user.ID == testUuid
}), mock.Anything).Return("", conflictErr).Once()
// 3. Conflict double-check: Search by EMAIL to see if it's the same person
// If NOT found by email, it means it's a different person using this UUID
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "conflict@test.com").Return("", nil).Once()
payload := map[string]any{
"users": []map[string]any{
{
"email": "conflict@test.com",
"name": "Conflict User",
"tenantSlug": "test-tenant",
"uuid": testUuid,
},
},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, 200, resp.StatusCode)
var result struct {
Results []bulkUserResult `json:"results"`
}
json.NewDecoder(resp.Body).Decode(&result)
assert.Len(t, result.Results, 1)
assert.False(t, result.Results[0].Success)
assert.Contains(t, result.Results[0].Message, "Conflict: UUID already exists")
})
t.Run("Fail - Invalid UUID Format", func(t *testing.T) {
invalidUuid := "not-a-uuid"
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
ID: "t-1",
Slug: "test-tenant",
}, nil).Once()
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
// 1. Search-first check (even for invalid format, it currently tries to search)
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, invalidUuid).Return("", nil).Once()
payload := map[string]any{
"users": []map[string]any{
{
"email": "invalid@test.com",
"name": "Invalid User",
"tenantSlug": "test-tenant",
"id": invalidUuid,
},
},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, 200, resp.StatusCode)
var result struct {
Results []bulkUserResult `json:"results"`
}
json.NewDecoder(resp.Body).Decode(&result)
assert.Len(t, result.Results, 1)
assert.False(t, result.Results[0].Success)
assert.Contains(t, result.Results[0].Message, "invalid UUID format")
})
t.Run("Success - Import after Delete (Visibility Check)", func(t *testing.T) {
// This test is to ensure that if a user is deleted and then re-imported with the same UUID,
// the process succeeds and ideally would result in a visible user.
// NOTE: In this unit test we mock the Repository, so we can't fully test DB behavior,
// but we can verify the Handler logic flow.
testUuid := "550e8400-e29b-41d4-a716-446655440007"
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
ID: "t-1",
Slug: "test-tenant",
}, nil).Once()
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
// 1. Search-first: Simulate NOT found (it was deleted from Kratos)
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, testUuid).Return("", nil).Once()
// 2. Create identity: Succeeds and returns a NEW Kratos ID
newKratosId := "new-kratos-id-after-delete"
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
return user.ID == testUuid
}), mock.Anything).Return(newKratosId, nil).Once()
payload := map[string]any{
"users": []map[string]any{
{
"email": "reimport@test.com",
"name": "Re-import User",
"tenantSlug": "test-tenant",
"id": testUuid,
},
},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, 200, resp.StatusCode)
var result struct {
Results []bulkUserResult `json:"results"`
}
json.NewDecoder(resp.Body).Decode(&result)
assert.True(t, result.Results[0].Success)
assert.Equal(t, testUuid, result.Results[0].UserID)
})
}

View File

@@ -9,6 +9,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"maps"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -196,14 +197,10 @@ func TestGetClient_ClientSecretFallbackPaths(t *testing.T) {
}) })
secretRepo := &mockSecretRepo{secrets: map[string]string{}} secretRepo := &mockSecretRepo{secrets: map[string]string{}}
for k, v := range tt.initialSecrets { maps.Copy(secretRepo.secrets, tt.initialSecrets)
secretRepo.secrets[k] = v
}
redisRepo := &mockRedisRepo{data: map[string]string{}} redisRepo := &mockRedisRepo{data: map[string]string{}}
for k, v := range tt.initialRedis { maps.Copy(redisRepo.data, tt.initialRedis)
redisRepo.data[k] = v
}
h := &handler.DevHandler{ h := &handler.DevHandler{
Hydra: &service.HydraAdminService{ Hydra: &service.HydraAdminService{

View File

@@ -104,11 +104,11 @@ func SanitizeClientLogMessage(message string) string {
return sanitized return sanitized
} }
func SanitizeClientLogData(data map[string]interface{}) map[string]interface{} { func SanitizeClientLogData(data map[string]any) map[string]any {
if len(data) == 0 { if len(data) == 0 {
return data return data
} }
out := make(map[string]interface{}, len(data)) out := make(map[string]any, len(data))
for k, v := range data { for k, v := range data {
if isSensitiveClientLogKey(k) { if isSensitiveClientLogKey(k) {
out[k] = "*****" out[k] = "*****"
@@ -119,12 +119,12 @@ func SanitizeClientLogData(data map[string]interface{}) map[string]interface{} {
return out return out
} }
func sanitizeClientLogValue(v interface{}) interface{} { func sanitizeClientLogValue(v any) any {
switch val := v.(type) { switch val := v.(type) {
case map[string]interface{}: case map[string]any:
return SanitizeClientLogData(val) return SanitizeClientLogData(val)
case []interface{}: case []any:
next := make([]interface{}, len(val)) next := make([]any, len(val))
for i := range val { for i := range val {
next[i] = sanitizeClientLogValue(val[i]) next[i] = sanitizeClientLogValue(val[i])
} }

View File

@@ -57,15 +57,15 @@ func TestShouldFilterNoisyClientInfo(t *testing.T) {
} }
func TestSanitizeClientLogData(t *testing.T) { func TestSanitizeClientLogData(t *testing.T) {
input := map[string]interface{}{ input := map[string]any{
"token": "raw-token", "token": "raw-token",
"safe": "ok", "safe": "ok",
"nested": map[string]interface{}{ "nested": map[string]any{
"new_password": "secret-1", "new_password": "secret-1",
"path": "/ko/profile", "path": "/ko/profile",
}, },
"arr": []interface{}{ "arr": []any{
map[string]interface{}{"authorization": "Bearer abc"}, map[string]any{"authorization": "Bearer abc"},
"token=abc123", "token=abc123",
}, },
} }
@@ -75,12 +75,12 @@ func TestSanitizeClientLogData(t *testing.T) {
assert.Equal(t, "*****", result["token"]) assert.Equal(t, "*****", result["token"])
assert.Equal(t, "ok", result["safe"]) assert.Equal(t, "ok", result["safe"])
nested := result["nested"].(map[string]interface{}) nested := result["nested"].(map[string]any)
assert.Equal(t, "*****", nested["new_password"]) assert.Equal(t, "*****", nested["new_password"])
assert.Equal(t, "/ko/profile", nested["path"]) assert.Equal(t, "/ko/profile", nested["path"])
arr := result["arr"].([]interface{}) arr := result["arr"].([]any)
first := arr[0].(map[string]interface{}) first := arr[0].(map[string]any)
assert.Equal(t, "*****", first["authorization"]) assert.Equal(t, "*****", first["authorization"])
assert.Equal(t, "token=*****", arr[1]) assert.Equal(t, "token=*****", arr[1])
} }

View File

@@ -23,7 +23,6 @@ func TestResolveBackendLogLevel_DefaultsByAppEnv(t *testing.T) {
} }
for _, tc := range testCases { for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()

View File

@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"maps"
"reflect" "reflect"
"sync" "sync"
"time" "time"
@@ -28,7 +29,7 @@ func isNil(i any) bool {
return true return true
} }
v := reflect.ValueOf(i) v := reflect.ValueOf(i)
return v.Kind() == reflect.Ptr && v.IsNil() return v.Kind() == reflect.Pointer && v.IsNil()
} }
// AuditMiddleware provides comprehensive audit logging for write requests by default. // AuditMiddleware provides comprehensive audit logging for write requests by default.
@@ -153,10 +154,8 @@ func AuditMiddleware(config AuditConfig) fiber.Handler {
for key, value := range v { for key, value := range v {
details[key] = value details[key] = value
} }
case map[string]interface{}: case map[string]any:
for key, value := range v { maps.Copy(details, v)
details[key] = value
}
} }
} }
if skipTimeline, ok := c.Locals("auth_timeline_skip").(bool); ok && skipTimeline { if skipTimeline, ok := c.Locals("auth_timeline_skip").(bool); ok && skipTimeline {

View File

@@ -44,7 +44,7 @@ func (r *clientConsentRepo) Find(ctx context.Context, clientID, subject string)
func (r *clientConsentRepo) Upsert(ctx context.Context, consent *domain.ClientConsent) error { func (r *clientConsentRepo) Upsert(ctx context.Context, consent *domain.ClientConsent) error {
return r.db.WithContext(ctx).Unscoped(). return r.db.WithContext(ctx).Unscoped().
Where("client_id = ? AND subject = ?", consent.ClientID, consent.Subject). Where("client_id = ? AND subject = ?", consent.ClientID, consent.Subject).
Assign(map[string]interface{}{ Assign(map[string]any{
"granted_scopes": consent.GrantedScopes, "granted_scopes": consent.GrantedScopes,
"updated_at": gorm.Expr("NOW()"), "updated_at": gorm.Expr("NOW()"),
"deleted_at": nil, "deleted_at": nil,

View File

@@ -70,7 +70,7 @@ func (r *ketoOutboxRepository) ListCurrentBySubject(ctx context.Context, namespa
} }
func (r *ketoOutboxRepository) UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error { func (r *ketoOutboxRepository) UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error {
return r.db.WithContext(ctx).Model(&domain.KetoOutbox{}).Where("id = ?", id).Updates(map[string]interface{}{ return r.db.WithContext(ctx).Model(&domain.KetoOutbox{}).Where("id = ?", id).Updates(map[string]any{
"status": status, "status": status,
"retry_count": retryCount, "retry_count": retryCount,
"last_error": lastError, "last_error": lastError,
@@ -80,7 +80,7 @@ func (r *ketoOutboxRepository) UpdateStatus(ctx context.Context, id string, stat
func (r *ketoOutboxRepository) MarkProcessed(ctx context.Context, id string) error { func (r *ketoOutboxRepository) MarkProcessed(ctx context.Context, id string) error {
now := time.Now() now := time.Now()
return r.db.WithContext(ctx).Model(&domain.KetoOutbox{}).Where("id = ?", id).Updates(map[string]interface{}{ return r.db.WithContext(ctx).Model(&domain.KetoOutbox{}).Where("id = ?", id).Updates(map[string]any{
"status": domain.KetoOutboxStatusProcessed, "status": domain.KetoOutboxStatusProcessed,
"processed_at": &now, "processed_at": &now,
"updated_at": now, "updated_at": now,

View File

@@ -76,7 +76,7 @@ func (r *userProjectionRepository) CountTenantMembers(ctx context.Context, tenan
} }
valuePlaceholders := make([]string, 0, len(tenants)) valuePlaceholders := make([]string, 0, len(tenants))
args := make([]interface{}, 0, len(tenants)*2) args := make([]any, 0, len(tenants)*2)
for _, tenant := range tenants { for _, tenant := range tenants {
valuePlaceholders = append(valuePlaceholders, "(?, ?)") valuePlaceholders = append(valuePlaceholders, "(?, ?)")
args = append(args, strings.TrimSpace(tenant.ID), strings.TrimSpace(tenant.Slug)) args = append(args, strings.TrimSpace(tenant.ID), strings.TrimSpace(tenant.Slug))
@@ -124,6 +124,14 @@ func (r *userProjectionRepository) ReplaceAllFromKratos(ctx context.Context, use
} }
if len(users) > 0 { if len(users) > 0 {
// [FIX] Handle email conflicts before bulk upsert
for _, u := range users {
if u.Email != "" {
// Hard-delete any record with same email but different ID to clear unique constraint
_ = tx.Unscoped().Where("email = ? AND id != ?", u.Email, u.ID).Delete(&domain.User{}).Error
}
}
if err := tx.Clauses(clause.OnConflict{ if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}}, Columns: []clause.Column{{Name: "id"}},
UpdateAll: true, UpdateAll: true,

View File

@@ -24,6 +24,7 @@ type UserRepository interface {
FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error) FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error)
FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error)
Delete(ctx context.Context, id string) error Delete(ctx context.Context, id string) error
DB() *gorm.DB
// Multiple identifiers support // Multiple identifiers support
UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error
@@ -40,18 +41,27 @@ func NewUserRepository(db *gorm.DB) UserRepository {
return &userRepository{db: db} return &userRepository{db: db}
} }
func (r *userRepository) DB() *gorm.DB {
return r.db
}
func (r *userRepository) Create(ctx context.Context, user *domain.User) error { func (r *userRepository) Create(ctx context.Context, user *domain.User) error {
return r.db.WithContext(ctx).Create(user).Error return r.db.WithContext(ctx).Create(user).Error
} }
func (r *userRepository) Update(ctx context.Context, user *domain.User) error { func (r *userRepository) Update(ctx context.Context, user *domain.User) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 1. Check for email collision (including soft-deleted)
var existing domain.User var existing domain.User
if err := tx.Unscoped().Where("email = ?", user.Email).First(&existing).Error; err == nil { if err := tx.Unscoped().Where("email = ?", user.Email).First(&existing).Error; err == nil {
// If email exists but ID is different, we MUST clear the old one to avoid unique constraint violation
if existing.ID != user.ID { if existing.ID != user.ID {
// [Restored] Check if the existing user is archived
if strings.EqualFold(strings.TrimSpace(existing.Status), domain.UserStatusArchived) { if strings.EqualFold(strings.TrimSpace(existing.Status), domain.UserStatusArchived) {
return fmt.Errorf("email is reserved by archived user: %s", user.Email) return fmt.Errorf("email is reserved by archived user: %s", user.Email)
} }
// HARD DELETE the old record and its associated login IDs to free up the email and identifiers
if err := tx.Unscoped().Where("user_id = ?", existing.ID).Delete(&domain.UserLoginID{}).Error; err != nil { if err := tx.Unscoped().Where("user_id = ?", existing.ID).Delete(&domain.UserLoginID{}).Error; err != nil {
return err return err
} }
@@ -61,9 +71,25 @@ func (r *userRepository) Update(ctx context.Context, user *domain.User) error {
} }
} }
return tx.Clauses(clause.OnConflict{ // 2. Perform Upsert on the new/target ID
Columns: []clause.Column{{Name: "id"}}, return tx.Unscoped().Clauses(clause.OnConflict{
UpdateAll: true, Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.Assignments(map[string]any{
"email": user.Email,
"name": user.Name,
"phone": user.Phone,
"role": user.Role,
"status": user.Status,
"department": user.Department,
"grade": user.Grade,
"position": user.Position,
"job_title": user.JobTitle,
"metadata": user.Metadata,
"tenant_id": user.TenantID,
"affiliation_type": user.AffiliationType,
"updated_at": user.UpdatedAt,
"deleted_at": nil, // Ensure it's active
}),
}).Create(user).Error }).Create(user).Error
}) })
} }
@@ -222,8 +248,9 @@ func (r *userRepository) Delete(ctx context.Context, id string) error {
func (r *userRepository) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error { func (r *userRepository) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Delete existing login IDs for this user // [FIX] Use Unscoped to permanently delete existing login IDs for this user
if err := tx.Where("user_id = ?", userID).Delete(&domain.UserLoginID{}).Error; err != nil { // This prevents unique constraint violations with soft-deleted records
if err := tx.Unscoped().Where("user_id = ?", userID).Delete(&domain.UserLoginID{}).Error; err != nil {
return err return err
} }

View File

@@ -3,16 +3,16 @@ package response
import "github.com/gofiber/fiber/v2" import "github.com/gofiber/fiber/v2"
type ErrorResponse struct { type ErrorResponse struct {
Error string `json:"error"` Error string `json:"error"`
Code string `json:"code,omitempty"` Code string `json:"code,omitempty"`
Details interface{} `json:"details,omitempty"` Details any `json:"details,omitempty"`
} }
func Error(c *fiber.Ctx, status int, code, message string) error { func Error(c *fiber.Ctx, status int, code, message string) error {
return ErrorWithDetails(c, status, code, message, nil) return ErrorWithDetails(c, status, code, message, nil)
} }
func ErrorWithDetails(c *fiber.Ctx, status int, code, message string, details interface{}) error { func ErrorWithDetails(c *fiber.Ctx, status int, code, message string, details any) error {
resp := ErrorResponse{ resp := ErrorResponse{
Error: message, Error: message,
Code: code, Code: code,

View File

@@ -36,13 +36,13 @@ func TestBackchannelLogoutService_BuildLogoutToken(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
var claims struct { var claims struct {
Issuer string `json:"iss"` Issuer string `json:"iss"`
Subject string `json:"sub"` Subject string `json:"sub"`
Aud interface{} `json:"aud"` Aud any `json:"aud"`
Iat int64 `json:"iat"` Iat int64 `json:"iat"`
Jti string `json:"jti"` Jti string `json:"jti"`
Sid string `json:"sid"` Sid string `json:"sid"`
Events map[string]interface{} `json:"events"` Events map[string]any `json:"events"`
} }
require.NoError(t, parsed.Claims(jwks.Keys[0].Key, &claims)) require.NoError(t, parsed.Claims(jwks.Keys[0].Key, &claims))
@@ -51,7 +51,7 @@ func TestBackchannelLogoutService_BuildLogoutToken(t *testing.T) {
switch aud := claims.Aud.(type) { switch aud := claims.Aud.(type) {
case string: case string:
assert.Equal(t, "client-1", aud) assert.Equal(t, "client-1", aud)
case []interface{}: case []any:
assert.Len(t, aud, 1) assert.Len(t, aud, 1)
assert.Equal(t, "client-1", aud[0]) assert.Equal(t, "client-1", aud[0])
default: default:

View File

@@ -65,21 +65,21 @@ func (s *DeveloperService) ListRequests(ctx context.Context, userID, status stri
} }
func (s *DeveloperService) ApproveRequest(ctx context.Context, id uint, adminNotes string) error { func (s *DeveloperService) ApproveRequest(ctx context.Context, id uint, adminNotes string) error {
return s.db.WithContext(ctx).Model(&domain.DeveloperRequest{}).Where("id = ?", id).Updates(map[string]interface{}{ return s.db.WithContext(ctx).Model(&domain.DeveloperRequest{}).Where("id = ?", id).Updates(map[string]any{
"status": domain.DeveloperRequestStatusApproved, "status": domain.DeveloperRequestStatusApproved,
"admin_notes": adminNotes, "admin_notes": adminNotes,
}).Error }).Error
} }
func (s *DeveloperService) RejectRequest(ctx context.Context, id uint, adminNotes string) error { func (s *DeveloperService) RejectRequest(ctx context.Context, id uint, adminNotes string) error {
return s.db.WithContext(ctx).Model(&domain.DeveloperRequest{}).Where("id = ?", id).Updates(map[string]interface{}{ return s.db.WithContext(ctx).Model(&domain.DeveloperRequest{}).Where("id = ?", id).Updates(map[string]any{
"status": domain.DeveloperRequestStatusRejected, "status": domain.DeveloperRequestStatusRejected,
"admin_notes": adminNotes, "admin_notes": adminNotes,
}).Error }).Error
} }
func (s *DeveloperService) CancelApprovedRequest(ctx context.Context, id uint, adminNotes string) error { func (s *DeveloperService) CancelApprovedRequest(ctx context.Context, id uint, adminNotes string) error {
return s.db.WithContext(ctx).Model(&domain.DeveloperRequest{}).Where("id = ?", id).Updates(map[string]interface{}{ return s.db.WithContext(ctx).Model(&domain.DeveloperRequest{}).Where("id = ?", id).Updates(map[string]any{
"status": domain.DeveloperRequestStatusCancelled, "status": domain.DeveloperRequestStatusCancelled,
"admin_notes": adminNotes, "admin_notes": adminNotes,
}).Error }).Error

View File

@@ -309,7 +309,7 @@ func (s *HeadlessJWKSCacheService) refreshClient(ctx context.Context, client dom
updated := *previous updated := *previous
updated.JWKSURI = jwksURI updated.JWKSURI = jwksURI
updated.LastCheckedAt = &now updated.LastCheckedAt = &now
updated.ExpiresAt = ptrTime(now.Add(s.TTL)) updated.ExpiresAt = new(now.Add(s.TTL))
updated.NextRetryAt = nil updated.NextRetryAt = nil
updated.LastRefreshStatus = "success" updated.LastRefreshStatus = "success"
updated.LastError = "" updated.LastError = ""
@@ -339,7 +339,7 @@ func (s *HeadlessJWKSCacheService) refreshClient(ctx context.Context, client dom
ClientID: client.ClientID, ClientID: client.ClientID,
JWKSURI: jwksURI, JWKSURI: jwksURI,
CachedAt: &now, CachedAt: &now,
ExpiresAt: ptrTime(now.Add(s.TTL)), ExpiresAt: new(now.Add(s.TTL)),
LastCheckedAt: &now, LastCheckedAt: &now,
NextRetryAt: nil, NextRetryAt: nil,
LastSuccessfulVerificationAt: previousLastVerification(previous), LastSuccessfulVerificationAt: previousLastVerification(previous),
@@ -379,7 +379,7 @@ func (s *HeadlessJWKSCacheService) persistRefreshFailure(client domain.HydraClie
state.ConsecutiveFailures = previous.ConsecutiveFailures + 1 state.ConsecutiveFailures = previous.ConsecutiveFailures + 1
} }
if s.shouldBackoff(state.ConsecutiveFailures) { if s.shouldBackoff(state.ConsecutiveFailures) {
state.NextRetryAt = ptrTime(now.Add(s.failureBackoffDuration())) state.NextRetryAt = new(now.Add(s.failureBackoffDuration()))
} }
_ = s.SaveState(client.ClientID, state) _ = s.SaveState(client.ClientID, state)
return &state return &state
@@ -480,8 +480,9 @@ func previousLastVerification(previous *domain.HeadlessJWKSCacheState) *time.Tim
return previous.LastSuccessfulVerificationAt return previous.LastSuccessfulVerificationAt
} }
//go:fix inline
func ptrTime(value time.Time) *time.Time { func ptrTime(value time.Time) *time.Time {
return &value return new(value)
} }
func (w *HeadlessJWKSCacheWorker) Start(ctx context.Context) { func (w *HeadlessJWKSCacheWorker) Start(ctx context.Context) {

View File

@@ -70,7 +70,7 @@ func TestHeadlessJWKSCacheService_EnsureFreshKeySet_UsesCachedJWKSWhenFresh(t *t
CachedKids: []string{"cached-key"}, CachedKids: []string{"cached-key"},
CachedAt: &now, CachedAt: &now,
LastCheckedAt: &now, LastCheckedAt: &now,
ExpiresAt: ptrTestTime(now.Add(30 * time.Minute)), ExpiresAt: new(now.Add(30 * time.Minute)),
LastRefreshStatus: "success", LastRefreshStatus: "success",
ConsecutiveFailures: 0, ConsecutiveFailures: 0,
}) })
@@ -114,7 +114,7 @@ func TestHeadlessJWKSCacheService_EnsureFreshKeySet_RefreshesWhenKidMissing(t *t
CachedKids: []string{"stale-key"}, CachedKids: []string{"stale-key"},
CachedAt: &now, CachedAt: &now,
LastCheckedAt: &now, LastCheckedAt: &now,
ExpiresAt: ptrTestTime(now.Add(30 * time.Minute)), ExpiresAt: new(now.Add(30 * time.Minute)),
LastRefreshStatus: "success", LastRefreshStatus: "success",
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -181,7 +181,7 @@ func TestHeadlessJWKSCacheService_ShouldPrefetch_SkipsUntilNextRetryAt(t *testin
ClientID: "client-headless", ClientID: "client-headless",
LastRefreshStatus: "failure", LastRefreshStatus: "failure",
ConsecutiveFailures: 3, ConsecutiveFailures: 3,
NextRetryAt: ptrTestTime(now.Add(10 * time.Minute)), NextRetryAt: new(now.Add(10 * time.Minute)),
} }
assert.False(t, cacheService.ShouldPrefetch(state, now)) assert.False(t, cacheService.ShouldPrefetch(state, now))
@@ -216,7 +216,7 @@ func TestHeadlessJWKSCacheWorker_RunOnce_SkipsBackoffTargets(t *testing.T) {
JWKSURI: clients[1].HeadlessJWKSURI(), JWKSURI: clients[1].HeadlessJWKSURI(),
LastRefreshStatus: "failure", LastRefreshStatus: "failure",
ConsecutiveFailures: 3, ConsecutiveFailures: 3,
NextRetryAt: ptrTestTime(now.Add(10 * time.Minute)), NextRetryAt: new(now.Add(10 * time.Minute)),
})) }))
fetchCounts := map[string]int{} fetchCounts := map[string]int{}
@@ -278,7 +278,7 @@ func TestHeadlessJWKSCacheWorker_RunOnce_RetriesAfterBackoffAndClearsFailureStat
LastRefreshStatus: "failure", LastRefreshStatus: "failure",
LastError: "previous failure", LastError: "previous failure",
ConsecutiveFailures: 3, ConsecutiveFailures: 3,
NextRetryAt: ptrTestTime(time.Now().Add(-time.Minute)), NextRetryAt: new(time.Now().Add(-time.Minute)),
})) }))
fetchCount := 0 fetchCount := 0
@@ -353,7 +353,7 @@ func TestHeadlessJWKSCacheWorker_RunOnce_MixedClients(t *testing.T) {
JWKSURI: skipClient.HeadlessJWKSURI(), JWKSURI: skipClient.HeadlessJWKSURI(),
LastRefreshStatus: "failure", LastRefreshStatus: "failure",
ConsecutiveFailures: 3, ConsecutiveFailures: 3,
NextRetryAt: ptrTestTime(time.Now().Add(10 * time.Minute)), NextRetryAt: new(time.Now().Add(10 * time.Minute)),
})) }))
fetchCounts := map[string]int{} fetchCounts := map[string]int{}
@@ -416,8 +416,9 @@ func mustServiceHeadlessRSAJWK(t *testing.T, kid string) (*rsa.PrivateKey, jose.
return privateKey, jose.JSONWebKeySet{Keys: []jose.JSONWebKey{publicJWK}} return privateKey, jose.JSONWebKeySet{Keys: []jose.JSONWebKey{publicJWK}}
} }
//go:fix inline
func ptrTestTime(value time.Time) *time.Time { func ptrTestTime(value time.Time) *time.Time {
return &value return new(value)
} }
func newTestHeadlessClient(clientID, jwksURI string) domain.HydraClient { func newTestHeadlessClient(clientID, jwksURI string) domain.HydraClient {

View File

@@ -97,7 +97,7 @@ func (s *HydraAdminService) GetClient(ctx context.Context, clientID string) (*do
func (s *HydraAdminService) PatchClientStatus(ctx context.Context, clientID, status string) (*domain.HydraClient, error) { func (s *HydraAdminService) PatchClientStatus(ctx context.Context, clientID, status string) (*domain.HydraClient, error) {
// JSON Patch format // JSON Patch format
payload := []map[string]interface{}{ payload := []map[string]any{
{ {
"op": "replace", "op": "replace",
"path": "/metadata/status", "path": "/metadata/status",
@@ -396,7 +396,7 @@ func (s *HydraAdminService) RejectConsentRequest(ctx context.Context, challenge
return nil, err return nil, err
} }
payload := map[string]interface{}{ payload := map[string]any{
"error": "access_denied", "error": "access_denied",
"error_description": "The user decided to reject the consent request.", "error_description": "The user decided to reject the consent request.",
} }
@@ -438,7 +438,7 @@ func (s *HydraAdminService) RejectLoginRequest(ctx context.Context, challenge, e
return nil, err return nil, err
} }
payload := map[string]interface{}{ payload := map[string]any{
"error": error, "error": error,
"error_description": errorDescription, "error_description": errorDescription,
} }
@@ -513,7 +513,7 @@ func (s *HydraAdminService) AcceptConsentRequest(ctx context.Context, challenge
return nil, err return nil, err
} }
payload := map[string]interface{}{ payload := map[string]any{
"grant_scope": grantInfo.RequestedScope, "grant_scope": grantInfo.RequestedScope,
"grant_audience": grantInfo.RequestedAudience, "grant_audience": grantInfo.RequestedAudience,
"remember": true, "remember": true,
@@ -564,7 +564,7 @@ func (s *HydraAdminService) AcceptLoginRequest(ctx context.Context, challenge st
return nil, err return nil, err
} }
payload := map[string]interface{}{ payload := map[string]any{
"subject": subject, "subject": subject,
"remember": true, "remember": true,
"remember_for": 2592000, "remember_for": 2592000,
@@ -600,13 +600,13 @@ func (s *HydraAdminService) AcceptLoginRequest(ctx context.Context, challenge st
} }
type HydraIntrospectionResponse struct { type HydraIntrospectionResponse struct {
Active bool `json:"active"` Active bool `json:"active"`
Subject string `json:"sub"` Subject string `json:"sub"`
ClientID string `json:"client_id"` ClientID string `json:"client_id"`
Scope string `json:"scope"` Scope string `json:"scope"`
ExpiresAt int64 `json:"exp"` ExpiresAt int64 `json:"exp"`
IssuedAt int64 `json:"iat"` IssuedAt int64 `json:"iat"`
Ext map[string]interface{} `json:"ext"` Ext map[string]any `json:"ext"`
} }
func (s *HydraAdminService) IntrospectToken(ctx context.Context, token string) (*HydraIntrospectionResponse, error) { func (s *HydraAdminService) IntrospectToken(ctx context.Context, token string) (*HydraIntrospectionResponse, error) {

View File

@@ -127,7 +127,7 @@ func TestHydraAdminService_PatchClientStatus(t *testing.T) {
assert.Equal(t, "PATCH", r.Method) assert.Equal(t, "PATCH", r.Method)
assert.Equal(t, "application/json-patch+json", r.Header.Get("Content-Type")) assert.Equal(t, "application/json-patch+json", r.Header.Get("Content-Type"))
var payload []map[string]interface{} var payload []map[string]any
_ = json.NewDecoder(r.Body).Decode(&payload) _ = json.NewDecoder(r.Body).Decode(&payload)
assert.Equal(t, "replace", payload[0]["op"]) assert.Equal(t, "replace", payload[0]["op"])
assert.Equal(t, "/metadata/status", payload[0]["path"]) assert.Equal(t, "/metadata/status", payload[0]["path"])
@@ -273,7 +273,7 @@ func TestHydraAdminService_AcceptLoginRequest(t *testing.T) {
assert.Equal(t, "/oauth2/auth/requests/login/accept", r.URL.Path) assert.Equal(t, "/oauth2/auth/requests/login/accept", r.URL.Path)
assert.Equal(t, challenge, r.URL.Query().Get("login_challenge")) assert.Equal(t, challenge, r.URL.Query().Get("login_challenge"))
var body map[string]interface{} var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body) _ = json.NewDecoder(r.Body).Decode(&body)
assert.Equal(t, subject, body["subject"]) assert.Equal(t, subject, body["subject"])

View File

@@ -110,7 +110,7 @@ func (s *ketoService) CheckPermission(ctx context.Context, subject, namespace, o
maxRetries := 5 maxRetries := 5
backoff := 200 * time.Millisecond backoff := 200 * time.Millisecond
for i := 0; i < maxRetries; i++ { for i := range maxRetries {
req, _ := http.NewRequestWithContext(ctx, "GET", u.String(), nil) req, _ := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
resp, err := s.client.Do(req) resp, err := s.client.Do(req)
if err == nil { if err == nil {
@@ -143,7 +143,7 @@ func (s *ketoService) CheckPermission(ctx context.Context, subject, namespace, o
func (s *ketoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error { func (s *ketoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
u := fmt.Sprintf("%s/admin/relation-tuples", s.writeURL) u := fmt.Sprintf("%s/admin/relation-tuples", s.writeURL)
payload := map[string]interface{}{ payload := map[string]any{
"namespace": namespace, "namespace": namespace,
"object": object, "object": object,
"relation": relation, "relation": relation,
@@ -156,7 +156,7 @@ func (s *ketoService) CreateRelation(ctx context.Context, namespace, object, rel
maxRetries := 5 maxRetries := 5
backoff := 200 * time.Millisecond backoff := 200 * time.Millisecond
for i := 0; i < maxRetries; i++ { for i := range maxRetries {
req, _ := http.NewRequestWithContext(ctx, "PUT", u, bytes.NewReader(body)) req, _ := http.NewRequestWithContext(ctx, "PUT", u, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
@@ -197,7 +197,7 @@ func (s *ketoService) DeleteRelation(ctx context.Context, namespace, object, rel
maxRetries := 5 maxRetries := 5
backoff := 200 * time.Millisecond backoff := 200 * time.Millisecond
for i := 0; i < maxRetries; i++ { for i := range maxRetries {
req, _ := http.NewRequestWithContext(ctx, "DELETE", u.String(), nil) req, _ := http.NewRequestWithContext(ctx, "DELETE", u.String(), nil)
resp, err := s.client.Do(req) resp, err := s.client.Do(req)
if err == nil { if err == nil {

View File

@@ -36,7 +36,7 @@ func TestKetoService_CreateRelation(t *testing.T) {
assert.Equal(t, "/admin/relation-tuples", r.URL.Path) assert.Equal(t, "/admin/relation-tuples", r.URL.Path)
assert.Equal(t, "PUT", r.Method) assert.Equal(t, "PUT", r.Method)
var body map[string]interface{} var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body) _ = json.NewDecoder(r.Body).Decode(&body)
assert.Equal(t, "tenants", body["namespace"]) assert.Equal(t, "tenants", body["namespace"])
assert.Equal(t, "tenant1", body["object"]) assert.Equal(t, "tenant1", body["object"])

View File

@@ -10,6 +10,7 @@ import (
"net" "net"
"net/http" "net/http"
"os" "os"
"regexp"
"strings" "strings"
"time" "time"
@@ -17,15 +18,15 @@ import (
) )
type KratosIdentity struct { type KratosIdentity struct {
ID string `json:"id"` ID string `json:"id"`
SchemaID string `json:"schema_id,omitempty"` SchemaID string `json:"schema_id,omitempty"`
Traits map[string]interface{} `json:"traits"` Traits map[string]any `json:"traits"`
State string `json:"state,omitempty"` State string `json:"state,omitempty"`
MetadataAdmin interface{} `json:"metadata_admin,omitempty"` MetadataAdmin any `json:"metadata_admin,omitempty"`
MetadataPublic interface{} `json:"metadata_public,omitempty"` MetadataPublic any `json:"metadata_public,omitempty"`
ExternalID string `json:"external_id,omitempty"` ExternalID string `json:"external_id,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at,omitempty"` UpdatedAt time.Time `json:"updated_at"`
} }
type KratosSessionDevice struct { type KratosSessionDevice struct {
@@ -36,9 +37,9 @@ type KratosSessionDevice struct {
type KratosSession struct { type KratosSession struct {
ID string `json:"id"` ID string `json:"id"`
Active bool `json:"active"` Active bool `json:"active"`
AuthenticatedAt time.Time `json:"authenticated_at,omitempty"` AuthenticatedAt time.Time `json:"authenticated_at"`
ExpiresAt time.Time `json:"expires_at,omitempty"` ExpiresAt time.Time `json:"expires_at"`
IssuedAt time.Time `json:"issued_at,omitempty"` IssuedAt time.Time `json:"issued_at"`
Identity *KratosIdentity `json:"identity,omitempty"` Identity *KratosIdentity `json:"identity,omitempty"`
Devices []KratosSessionDevice `json:"devices,omitempty"` Devices []KratosSessionDevice `json:"devices,omitempty"`
} }
@@ -47,7 +48,7 @@ type KratosAdminService interface {
ListIdentities(ctx context.Context) ([]KratosIdentity, error) ListIdentities(ctx context.Context) ([]KratosIdentity, error)
FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error)
GetIdentity(ctx context.Context, identityID string) (*KratosIdentity, error) GetIdentity(ctx context.Context, identityID string) (*KratosIdentity, error)
UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error) UpdateIdentity(ctx context.Context, identityID string, traits map[string]any, state string) (*KratosIdentity, error)
UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error
DeleteIdentity(ctx context.Context, identityID string) error DeleteIdentity(ctx context.Context, identityID string) error
CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error)
@@ -99,13 +100,81 @@ func (s *kratosAdminService) FindIdentityIDByIdentifier(ctx context.Context, ide
} }
endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities" endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities"
// 1. Try credentials_identifier (Email/LoginID/Phone)
id, err := s.searchIdentities(ctx, endpoint, "credentials_identifier", identifier)
if err == nil && id != "" {
// VERIFY: Kratos sometimes ignores unknown query params and returns the first identity.
if s.verifyIdentityMatch(ctx, id, identifier) {
return id, nil
}
}
// 2. If it looks like a UUID, try external_id
if matched, _ := regexp.MatchString(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`, strings.ToLower(identifier)); matched {
id, err = s.searchIdentities(ctx, endpoint, "external_id", identifier)
if err == nil && id != "" {
if s.verifyIdentityMatch(ctx, id, identifier) {
return id, nil
}
}
// 3. Also try direct ID lookup
identity, err := s.GetIdentity(ctx, identifier)
if err == nil && identity != nil {
return identity.ID, nil
}
}
return "", nil
}
func (s *kratosAdminService) verifyIdentityMatch(ctx context.Context, id, identifier string) bool {
identity, err := s.GetIdentity(ctx, id)
if err != nil || identity == nil {
return false
}
// Exact ID match
if strings.EqualFold(identity.ID, identifier) {
return true
}
// Exact ExternalID match
if strings.EqualFold(identity.ExternalID, identifier) {
return true
}
// Check traits (Email, CustomLoginIDs)
if email, ok := identity.Traits["email"].(string); ok && strings.EqualFold(email, identifier) {
return true
}
if phone, ok := identity.Traits["phone_number"].(string); ok && strings.EqualFold(phone, identifier) {
return true
}
if lids, ok := identity.Traits["custom_login_ids"].([]any); ok {
for _, lid := range lids {
if s, ok := lid.(string); ok && strings.EqualFold(s, identifier) {
return true
}
}
} else if lids, ok := identity.Traits["custom_login_ids"].([]string); ok {
for _, lid := range lids {
if strings.EqualFold(lid, identifier) {
return true
}
}
}
return false
}
func (s *kratosAdminService) searchIdentities(ctx context.Context, endpoint, key, value string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil { if err != nil {
return "", err return "", err
} }
query := req.URL.Query() query := req.URL.Query()
query.Set("credentials_identifier", identifier) query.Set(key, value)
req.URL.RawQuery = query.Encode() req.URL.RawQuery = query.Encode()
resp, err := s.httpClient().Do(req) resp, err := s.httpClient().Do(req)
@@ -119,7 +188,7 @@ func (s *kratosAdminService) FindIdentityIDByIdentifier(ctx context.Context, ide
} }
if resp.StatusCode >= 300 { if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return "", fmt.Errorf("kratos admin search failed status=%d body=%s", resp.StatusCode, string(body)) return "", fmt.Errorf("kratos admin search by %s failed status=%d body=%s", key, resp.StatusCode, string(body))
} }
var identities []struct { var identities []struct {
@@ -162,8 +231,8 @@ func (s *kratosAdminService) GetIdentity(ctx context.Context, identityID string)
return &identity, nil return &identity, nil
} }
func (s *kratosAdminService) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error) { func (s *kratosAdminService) UpdateIdentity(ctx context.Context, identityID string, traits map[string]any, state string) (*KratosIdentity, error) {
payload := map[string]interface{}{ payload := map[string]any{
"schema_id": "default", "schema_id": "default",
"traits": traits, "traits": traits,
} }
@@ -211,12 +280,12 @@ func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identit
return err return err
} }
payload := map[string]interface{}{ payload := map[string]any{
"schema_id": identity.SchemaID, "schema_id": identity.SchemaID,
"traits": identity.Traits, "traits": identity.Traits,
"state": identity.State, "state": identity.State,
"credentials": map[string]interface{}{ "credentials": map[string]any{
"password": map[string]interface{}{ "password": map[string]any{
"config": map[string]string{ "config": map[string]string{
"hashed_password": hashedPassword, "hashed_password": hashedPassword,
}, },
@@ -264,7 +333,7 @@ func (s *kratosAdminService) CreateUser(ctx context.Context, user *domain.Broker
return "", fmt.Errorf("kratos admin: user payload is nil") return "", fmt.Errorf("kratos admin: user payload is nil")
} }
traits := map[string]interface{}{ traits := map[string]any{
"email": user.Email, "email": user.Email,
"name": user.Name, "name": user.Name,
} }
@@ -278,11 +347,11 @@ func (s *kratosAdminService) CreateUser(ctx context.Context, user *domain.Broker
traits[k] = v traits[k] = v
} }
payload := map[string]interface{}{ payload := map[string]any{
"schema_id": "default", "schema_id": "default",
"traits": traits, "traits": traits,
"credentials": map[string]interface{}{ "credentials": map[string]any{
"password": map[string]interface{}{ "password": map[string]any{
"config": map[string]string{ "config": map[string]string{
"password": password, "password": password,
}, },

View File

@@ -103,7 +103,7 @@ func (m *MockKratosAdminServiceShared) GetIdentity(ctx context.Context, identity
return args.Get(0).(*KratosIdentity), args.Error(1) return args.Get(0).(*KratosIdentity), args.Error(1)
} }
func (m *MockKratosAdminServiceShared) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error) { func (m *MockKratosAdminServiceShared) UpdateIdentity(ctx context.Context, identityID string, traits map[string]any, state string) (*KratosIdentity, error) {
args := m.Called(ctx, identityID, traits, state) args := m.Called(ctx, identityID, traits, state)
if args.Get(0) == nil { if args.Get(0) == nil {
return nil, args.Error(1) return nil, args.Error(1)

View File

@@ -59,6 +59,13 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
} }
// 중복 확인 // 중복 확인
if user.ID != "" {
existing, err := o.getIdentity(user.ID)
if err == nil && existing != nil {
return "", fmt.Errorf("ory provider: identity already exists for uuid=%s", user.ID)
}
}
existingID, err := o.findIdentityID(user.Email) existingID, err := o.findIdentityID(user.Email)
if err != nil { if err != nil {
return "", fmt.Errorf("ory provider: search identity failed: %w", err) return "", fmt.Errorf("ory provider: search identity failed: %w", err)
@@ -102,7 +109,7 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
} }
} }
traits := map[string]interface{}{ traits := map[string]any{
"email": user.Email, "email": user.Email,
"name": user.Name, "name": user.Name,
} }
@@ -123,18 +130,26 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
traits[k] = v traits[k] = v
} }
payload := map[string]interface{}{ payload := map[string]any{
"schema_id": "default", "schema_id": "default",
"traits": traits, "traits": traits,
"credentials": map[string]interface{}{ "credentials": map[string]any{
"password": map[string]interface{}{ "password": map[string]any{
"config": map[string]string{ "config": map[string]string{
"password": password, "password": password,
}, },
}, },
}, },
} }
verifiable := []map[string]interface{}{ if user.ID != "" {
// Use external_id as a fallback/mapping for the requested ID
payload["external_id"] = user.ID
// Also store in metadata_admin for audit/migration purposes
payload["metadata_admin"] = map[string]any{
"original_uuid": user.ID,
}
}
verifiable := []map[string]any{
{ {
"value": user.Email, "value": user.Email,
"verified": true, "verified": true,
@@ -142,14 +157,14 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
}, },
} }
if user.PhoneNumber != "" { if user.PhoneNumber != "" {
verifiable = append(verifiable, map[string]interface{}{ verifiable = append(verifiable, map[string]any{
"value": user.PhoneNumber, "value": user.PhoneNumber,
"verified": true, "verified": true,
"via": "sms", "via": "sms",
}) })
} }
payload["verifiable_addresses"] = verifiable payload["verifiable_addresses"] = verifiable
payload["recovery_addresses"] = []map[string]interface{}{ payload["recovery_addresses"] = []map[string]any{
{ {
"value": user.Email, "value": user.Email,
"via": "email", "via": "email",
@@ -454,12 +469,12 @@ func (o *OryProvider) ensureCodeLoginIdentifier(loginID string) error {
existingIndex = idx existingIndex = idx
} }
} }
ops := make([]map[string]interface{}, 0, 2) ops := make([]map[string]any, 0, 2)
if !exists { if !exists {
ops = append(ops, map[string]interface{}{ ops = append(ops, map[string]any{
"op": "add", "op": "add",
"path": "/verifiable_addresses/-", "path": "/verifiable_addresses/-",
"value": map[string]interface{}{ "value": map[string]any{
"value": loginID, "value": loginID,
"via": via, "via": via,
"verified": true, "verified": true,
@@ -469,14 +484,14 @@ func (o *OryProvider) ensureCodeLoginIdentifier(loginID string) error {
} else { } else {
addr := identity.VerifiableAddresses[existingIndex] addr := identity.VerifiableAddresses[existingIndex]
if !addr.Verified { if !addr.Verified {
ops = append(ops, map[string]interface{}{ ops = append(ops, map[string]any{
"op": "replace", "op": "replace",
"path": fmt.Sprintf("/verifiable_addresses/%d/verified", existingIndex), "path": fmt.Sprintf("/verifiable_addresses/%d/verified", existingIndex),
"value": true, "value": true,
}) })
} }
if addr.Status != "" && addr.Status != "completed" { if addr.Status != "" && addr.Status != "completed" {
ops = append(ops, map[string]interface{}{ ops = append(ops, map[string]any{
"op": "replace", "op": "replace",
"path": fmt.Sprintf("/verifiable_addresses/%d/status", existingIndex), "path": fmt.Sprintf("/verifiable_addresses/%d/status", existingIndex),
"value": "completed", "value": "completed",
@@ -520,7 +535,7 @@ func (o *OryProvider) ensureCodeLoginIdentifier(loginID string) error {
}) })
} }
payload := map[string]interface{}{ payload := map[string]any{
"schema_id": fullIdentity.SchemaID, "schema_id": fullIdentity.SchemaID,
"traits": fullIdentity.Traits, "traits": fullIdentity.Traits,
"verifiable_addresses": addresses, "verifiable_addresses": addresses,
@@ -561,12 +576,12 @@ type kratosRecoveryAddress struct {
type kratosIdentityFull struct { type kratosIdentityFull struct {
SchemaID string `json:"schema_id"` SchemaID string `json:"schema_id"`
Traits map[string]interface{} `json:"traits"` Traits map[string]any `json:"traits"`
VerifiableAddresses []kratosVerifiableAddress `json:"verifiable_addresses"` VerifiableAddresses []kratosVerifiableAddress `json:"verifiable_addresses"`
RecoveryAddresses []kratosRecoveryAddress `json:"recovery_addresses"` RecoveryAddresses []kratosRecoveryAddress `json:"recovery_addresses"`
} }
func (o *OryProvider) patchIdentity(identityID string, ops []map[string]interface{}) error { func (o *OryProvider) patchIdentity(identityID string, ops []map[string]any) error {
body, _ := json.Marshal(ops) body, _ := json.Marshal(ops)
req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body)) req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body))
if err != nil { if err != nil {
@@ -750,12 +765,12 @@ func (o *OryProvider) UpdateUserPassword(loginID, newPassword string, r *http.Re
return fmt.Errorf("ory provider: hash password failed: %w", err) return fmt.Errorf("ory provider: hash password failed: %w", err)
} }
payload := map[string]interface{}{ payload := map[string]any{
"schema_id": identity.SchemaID, "schema_id": identity.SchemaID,
"traits": identity.Traits, "traits": identity.Traits,
"state": identity.State, "state": identity.State,
"credentials": map[string]interface{}{ "credentials": map[string]any{
"password": map[string]interface{}{ "password": map[string]any{
"config": map[string]string{ "config": map[string]string{
"hashed_password": hashedPassword, "hashed_password": hashedPassword,
}, },
@@ -806,6 +821,57 @@ func getenv(key, fallback string) string {
return fallback return fallback
} }
// findIdentityByID: Kratos Admin API에서 ID(UUID)로 직접 조회
func (o *OryProvider) findIdentityByID(id string) (string, error) {
identity, err := o.getIdentity(id)
if err != nil {
return "", err
}
if identity != nil {
return identity.ID, nil
}
return "", nil
}
// findIdentityByExternalID: external_id 필드로 identity 검색
func (o *OryProvider) findIdentityByExternalID(externalID string) (string, error) {
u, err := url.Parse(fmt.Sprintf("%s/admin/identities", o.KratosAdminURL))
if err != nil {
return "", err
}
query := u.Query()
// Kratos v1.1+ supports filtering by external_id
query.Set("external_id", externalID)
u.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, u.String(), nil)
if err != nil {
return "", err
}
resp, err := o.httpClient().Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return "", nil // Ignore errors for search
}
var identities []struct {
ID string `json:"id"`
}
if err := json.NewDecoder(resp.Body).Decode(&identities); err != nil {
return "", nil
}
if len(identities) == 0 {
return "", nil
}
return identities[0].ID, nil
}
// findIdentityID: Kratos Admin API에서 credentials_identifier로 검색 후 첫 번째 identity id 반환 // findIdentityID: Kratos Admin API에서 credentials_identifier로 검색 후 첫 번째 identity id 반환
func (o *OryProvider) findIdentityID(loginID string) (string, error) { func (o *OryProvider) findIdentityID(loginID string) (string, error) {
u, err := url.Parse(fmt.Sprintf("%s/admin/identities", o.KratosAdminURL)) u, err := url.Parse(fmt.Sprintf("%s/admin/identities", o.KratosAdminURL))
@@ -837,7 +903,8 @@ func (o *OryProvider) findIdentityID(loginID string) (string, error) {
} }
var identities []struct { var identities []struct {
ID string `json:"id"` ID string `json:"id"`
Traits map[string]any `json:"traits"`
} }
if err := json.NewDecoder(resp.Body).Decode(&identities); err != nil { if err := json.NewDecoder(resp.Body).Decode(&identities); err != nil {
return "", fmt.Errorf("decode response failed: %w", err) return "", fmt.Errorf("decode response failed: %w", err)
@@ -845,7 +912,30 @@ func (o *OryProvider) findIdentityID(loginID string) (string, error) {
if len(identities) == 0 { if len(identities) == 0 {
return "", nil return "", nil
} }
return identities[0].ID, nil
// VERIFY: Double check traits to avoid Kratos ignoring the query param
candidate := identities[0]
if email, ok := candidate.Traits["email"].(string); ok && strings.EqualFold(email, loginID) {
return candidate.ID, nil
}
if phone, ok := candidate.Traits["phone_number"].(string); ok && strings.EqualFold(phone, loginID) {
return candidate.ID, nil
}
if lids, ok := candidate.Traits["custom_login_ids"].([]any); ok {
for _, lid := range lids {
if s, ok := lid.(string); ok && strings.EqualFold(s, loginID) {
return candidate.ID, nil
}
}
} else if lids, ok := candidate.Traits["custom_login_ids"].([]string); ok {
for _, lid := range lids {
if strings.EqualFold(lid, loginID) {
return candidate.ID, nil
}
}
}
return "", nil
} }
func (o *OryProvider) getIdentity(identityID string) (*KratosIdentity, error) { func (o *OryProvider) getIdentity(identityID string) (*KratosIdentity, error) {

View File

@@ -1,6 +1,7 @@
package service package service
import ( import (
"baron-sso-backend/internal/domain"
"bytes" "bytes"
"encoding/json" "encoding/json"
"io" "io"
@@ -50,19 +51,24 @@ func TestUpdateUserPassword_Success(t *testing.T) {
if got := q.Get("credentials_identifier"); got != loginID { if got := q.Get("credentials_identifier"); got != loginID {
t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, got) t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, got)
} }
_ = json.NewEncoder(w).Encode([]map[string]string{ _ = json.NewEncoder(w).Encode([]map[string]any{
{"id": identityID}, {
"id": identityID,
"traits": map[string]any{
"email": loginID,
},
},
}) })
return return
} }
if r.URL.Path != "/admin/identities/"+identityID { if r.URL.Path != "/admin/identities/"+identityID {
t.Fatalf("unexpected identity lookup path: %s", r.URL.Path) t.Fatalf("unexpected identity lookup path: %s", r.URL.Path)
} }
_ = json.NewEncoder(w).Encode(map[string]interface{}{ _ = json.NewEncoder(w).Encode(map[string]any{
"id": identityID, "id": identityID,
"schema_id": "default", "schema_id": "default",
"state": "active", "state": "active",
"traits": map[string]interface{}{ "traits": map[string]any{
"email": loginID, "email": loginID,
}, },
}) })
@@ -120,17 +126,22 @@ func TestUpdateUserPassword_ServerError(t *testing.T) {
switch { switch {
case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet: case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet:
if r.URL.Path == "/admin/identities" { if r.URL.Path == "/admin/identities" {
_ = json.NewEncoder(w).Encode([]map[string]string{ _ = json.NewEncoder(w).Encode([]map[string]any{
{"id": "abc"}, {
"id": "abc",
"traits": map[string]any{
"email": "user@example.com",
},
},
}) })
return return
} }
if r.URL.Path == "/admin/identities/abc" { if r.URL.Path == "/admin/identities/abc" {
_ = json.NewEncoder(w).Encode(map[string]interface{}{ _ = json.NewEncoder(w).Encode(map[string]any{
"id": "abc", "id": "abc",
"schema_id": "default", "schema_id": "default",
"state": "active", "state": "active",
"traits": map[string]interface{}{ "traits": map[string]any{
"email": "user@example.com", "email": "user@example.com",
}, },
}) })
@@ -163,8 +174,13 @@ func TestFindIdentityID_QueryEncoding(t *testing.T) {
if values.Get("credentials_identifier") != loginID { if values.Get("credentials_identifier") != loginID {
t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, values.Get("credentials_identifier")) t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, values.Get("credentials_identifier"))
} }
_ = json.NewEncoder(w).Encode([]map[string]string{ _ = json.NewEncoder(w).Encode([]map[string]any{
{"id": "id-123"}, {
"id": "id-123",
"traits": map[string]any{
"email": loginID,
},
},
}) })
}) })
@@ -181,3 +197,67 @@ func TestFindIdentityID_QueryEncoding(t *testing.T) {
t.Fatalf("expected id-123, got %s", id) t.Fatalf("expected id-123, got %s", id)
} }
} }
func TestOryProvider_CreateUser_CustomIDSupport(t *testing.T) {
const (
email = "newuser@test.com"
name = "New User"
customUuid = "550e8400-e29b-41d4-a716-446655440000"
password = "secret123456"
kratosId = "kratos-gen-id"
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/admin/identities" && r.Method == http.MethodGet:
// No existing identity
_ = json.NewEncoder(w).Encode([]any{})
return
case r.URL.Path == "/admin/identities/"+customUuid && r.Method == http.MethodGet:
// No identity with this ID
http.NotFound(w, r)
return
case r.URL.Path == "/admin/identities" && r.Method == http.MethodPost:
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
// Verify ID is NOT in the root to avoid "unknown field id" error
if _, exists := body["id"]; exists {
t.Fatalf("payload MUST NOT contain root 'id' field for compatibility")
}
// Verify external_id and metadata_admin
if got := body["external_id"]; got != customUuid {
t.Fatalf("expected external_id %s, got %v", customUuid, got)
}
meta, ok := body["metadata_admin"].(map[string]any)
if !ok || meta["original_uuid"] != customUuid {
t.Fatalf("expected metadata_admin.original_uuid %s, got %v", customUuid, meta)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"id": kratosId,
})
return
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
}
})
provider := &OryProvider{
KratosAdminURL: "http://kratos-admin.local",
HTTPClient: clientForHandler(handler),
}
id, err := provider.CreateUser(&domain.BrokerUser{
ID: customUuid,
Email: email,
Name: name,
}, password)
if err != nil {
t.Fatalf("CreateUser failed: %v", err)
}
if id != kratosId {
t.Fatalf("expected Kratos generated ID %s, got %s", kratosId, id)
}
}

View File

@@ -100,7 +100,7 @@ func (s *relyingPartyService) enqueueDefaultRelyingPartyRelations(ctx context.Co
func (s *relyingPartyService) Create(ctx context.Context, tenantID string, client domain.HydraClient) (*domain.RelyingParty, error) { func (s *relyingPartyService) Create(ctx context.Context, tenantID string, client domain.HydraClient) (*domain.RelyingParty, error) {
// 1. Create Client in Hydra // 1. Create Client in Hydra
if client.Metadata == nil { if client.Metadata == nil {
client.Metadata = make(map[string]interface{}) client.Metadata = make(map[string]any)
} }
client.Metadata["tenant_id"] = tenantID client.Metadata["tenant_id"] = tenantID

View File

@@ -52,7 +52,7 @@ func TestRelyingPartyService_Create_Success(t *testing.T) {
tenantID := "tenant-1" tenantID := "tenant-1"
inputClient := domain.HydraClient{ inputClient := domain.HydraClient{
ClientName: "Test App", ClientName: "Test App",
Metadata: map[string]interface{}{ Metadata: map[string]any{
"user_id": "creator-1", "user_id": "creator-1",
}, },
} }
@@ -127,7 +127,7 @@ func TestRelyingPartyService_Get_Success(t *testing.T) {
_ = json.NewEncoder(w).Encode(domain.HydraClient{ _ = json.NewEncoder(w).Encode(domain.HydraClient{
ClientID: clientID, ClientID: clientID,
ClientName: "Hydra Name", ClientName: "Hydra Name",
Metadata: map[string]interface{}{ Metadata: map[string]any{
"tenant_id": "tenant-1", "tenant_id": "tenant-1",
}, },
}) })
@@ -180,7 +180,7 @@ func TestRelyingPartyService_Delete_Success(t *testing.T) {
if r.Method == http.MethodGet && strings.Contains(r.URL.Path, clientID) { if r.Method == http.MethodGet && strings.Contains(r.URL.Path, clientID) {
_ = json.NewEncoder(w).Encode(domain.HydraClient{ _ = json.NewEncoder(w).Encode(domain.HydraClient{
ClientID: clientID, ClientID: clientID,
Metadata: map[string]interface{}{ Metadata: map[string]any{
"tenant_id": tenantID, "tenant_id": tenantID,
"user_id": "creator-1", "user_id": "creator-1",
}, },

View File

@@ -8,7 +8,7 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/ioutil" "io"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
@@ -82,7 +82,7 @@ func (s *SmsServiceImpl) SendSms(to, content string) error {
} }
defer resp.Body.Close() defer resp.Body.Close()
respBody, err := ioutil.ReadAll(resp.Body) respBody, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return fmt.Errorf("error reading response body: %w", err) return fmt.Errorf("error reading response body: %w", err)
} }

View File

@@ -336,7 +336,7 @@ func (s *tenantService) ProvisionTenantByDomain(ctx context.Context, domainName
} }
for _, g := range groups { for _, g := range groups {
rawConfig, ok := g.Config["autoProvisioning"].(map[string]interface{}) rawConfig, ok := g.Config["autoProvisioning"].(map[string]any)
if !ok { if !ok {
continue continue
} }
@@ -346,12 +346,12 @@ func (s *tenantService) ProvisionTenantByDomain(ctx context.Context, domainName
continue continue
} }
mapping, ok := rawConfig["mappingRules"].(map[string]interface{}) mapping, ok := rawConfig["mappingRules"].(map[string]any)
if !ok { if !ok {
continue continue
} }
rule, ok := mapping[domainName].(map[string]interface{}) rule, ok := mapping[domainName].(map[string]any)
if !ok { if !ok {
continue continue
} }

View File

@@ -7,6 +7,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"gorm.io/gorm"
) )
// --- Local Mocks to avoid collisions --- // --- Local Mocks to avoid collisions ---
@@ -185,6 +186,10 @@ func (m *MockUserRepoForTenant) FindTenantIDByLoginID(ctx context.Context, login
return "", nil return "", nil
} }
func (m *MockUserRepoForTenant) DB() *gorm.DB {
return nil
}
// --- Tests --- // --- Tests ---
func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) { func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) {

View File

@@ -225,7 +225,7 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string
if err == nil && identity != nil { if err == nil && identity != nil {
traits := identity.Traits traits := identity.Traits
if traits == nil { if traits == nil {
traits = make(map[string]interface{}) traits = make(map[string]any)
} }
delete(traits, "companyCode") delete(traits, "companyCode")
delete(traits, "companyCodes") delete(traits, "companyCodes")
@@ -349,7 +349,7 @@ func mapUserGroupKratosIdentityToLocalUser(identity KratosIdentity) *domain.User
return user return user
} }
func userGroupTraitString(traits map[string]interface{}, key string) string { func userGroupTraitString(traits map[string]any, key string) string {
if traits == nil { if traits == nil {
return "" return ""
} }
@@ -363,14 +363,14 @@ func userGroupTraitString(traits map[string]interface{}, key string) string {
return fmt.Sprint(value) return fmt.Sprint(value)
} }
func userGroupTraitStringArray(traits map[string]interface{}, key string) []string { func userGroupTraitStringArray(traits map[string]any, key string) []string {
if traits == nil { if traits == nil {
return nil return nil
} }
switch value := traits[key].(type) { switch value := traits[key].(type) {
case []string: case []string:
return value return value
case []interface{}: case []any:
items := make([]string, 0, len(value)) items := make([]string, 0, len(value))
for _, item := range value { for _, item := range value {
if str, ok := item.(string); ok && str != "" { if str, ok := item.(string); ok && str != "" {

View File

@@ -135,6 +135,10 @@ func (m *MockUserRepository) FindTenantIDByLoginID(ctx context.Context, loginID
return "", nil return "", nil
} }
func (m *MockUserRepository) DB() *gorm.DB {
return nil
}
type MockKetoOutboxRepository struct { type MockKetoOutboxRepository struct {
mock.Mock mock.Mock
} }
@@ -250,7 +254,7 @@ func TestUserGroupService_AddMember(t *testing.T) {
// Mock Kratos // Mock Kratos
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&KratosIdentity{ mockKratos.On("GetIdentity", mock.Anything, userID).Return(&KratosIdentity{
ID: userID, ID: userID,
Traits: map[string]interface{}{"email": "user@test.com"}, Traits: map[string]any{"email": "user@test.com"},
State: "active", State: "active",
}, nil) }, nil)
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.Anything, "active").Return(&KratosIdentity{}, nil) mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.Anything, "active").Return(&KratosIdentity{}, nil)
@@ -295,18 +299,18 @@ func TestUserGroupService_AddMemberUpsertsLocalReadModelWhenMissing(t *testing.T
mockTenantRepo.On("FindByID", mock.Anything, tenantID).Return(&domain.Tenant{ID: tenantID, Slug: tenantSlug}, nil) mockTenantRepo.On("FindByID", mock.Anything, tenantID).Return(&domain.Tenant{ID: tenantID, Slug: tenantSlug}, nil)
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&KratosIdentity{ mockKratos.On("GetIdentity", mock.Anything, userID).Return(&KratosIdentity{
ID: userID, ID: userID,
Traits: map[string]interface{}{ Traits: map[string]any{
"email": "user@test.com", "email": "user@test.com",
"name": "User Test", "name": "User Test",
}, },
State: "active", State: "active",
}, nil) }, nil)
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool { mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]any) bool {
_, hasCompanyCode := traits["companyCode"] _, hasCompanyCode := traits["companyCode"]
return !hasCompanyCode && traits["tenant_id"] == tenantID && traits["department"] == "Sales" return !hasCompanyCode && traits["tenant_id"] == tenantID && traits["department"] == "Sales"
}), "active").Return(&KratosIdentity{ }), "active").Return(&KratosIdentity{
ID: userID, ID: userID,
Traits: map[string]interface{}{ Traits: map[string]any{
"email": "user@test.com", "email": "user@test.com",
"name": "User Test", "name": "User Test",
"tenant_id": tenantID, "tenant_id": tenantID,
@@ -402,7 +406,7 @@ func TestUserGroupService_Get_WithKratosFallback(t *testing.T) {
mockKratos.On("GetIdentity", mock.Anything, "u1").Return(&KratosIdentity{ mockKratos.On("GetIdentity", mock.Anything, "u1").Return(&KratosIdentity{
ID: "u1", ID: "u1",
Traits: map[string]interface{}{"name": "User One", "email": "user1@example.com"}, Traits: map[string]any{"name": "User One", "email": "user1@example.com"},
}, nil) }, nil)
group, err := svc.Get(context.Background(), groupID) group, err := svc.Get(context.Background(), groupID)

View File

@@ -110,7 +110,7 @@ func MapKratosIdentityToLocalUser(identity KratosIdentity) domain.User {
return user return user
} }
func kratosProjectionTraitString(traits map[string]interface{}, key string) string { func kratosProjectionTraitString(traits map[string]any, key string) string {
if traits == nil { if traits == nil {
return "" return ""
} }
@@ -124,14 +124,14 @@ func kratosProjectionTraitString(traits map[string]interface{}, key string) stri
return fmt.Sprint(value) return fmt.Sprint(value)
} }
func kratosProjectionTraitStringArray(traits map[string]interface{}, key string) []string { func kratosProjectionTraitStringArray(traits map[string]any, key string) []string {
if traits == nil { if traits == nil {
return nil return nil
} }
switch value := traits[key].(type) { switch value := traits[key].(type) {
case []string: case []string:
return value return value
case []interface{}: case []any:
items := make([]string, 0, len(value)) items := make([]string, 0, len(value))
for _, item := range value { for _, item := range value {
if str, ok := item.(string); ok && strings.TrimSpace(str) != "" { if str, ok := item.(string); ok && strings.TrimSpace(str) != "" {

View File

@@ -48,12 +48,12 @@ func TestUserProjectionSyncService_ReconcileReplacesProjectionFromKratos(t *test
kratos.On("ListIdentities", ctx).Return([]KratosIdentity{ kratos.On("ListIdentities", ctx).Return([]KratosIdentity{
{ {
ID: "00000000-0000-0000-0000-000000000101", ID: "00000000-0000-0000-0000-000000000101",
Traits: map[string]interface{}{ Traits: map[string]any{
"email": "one@example.com", "email": "one@example.com",
"name": "One", "name": "One",
"phone_number": "+821012345678", "phone_number": "+821012345678",
"companyCode": "saman", "companyCode": "saman",
"companyCodes": []interface{}{"saman", "group-a"}, "companyCodes": []any{"saman", "group-a"},
"tenant_id": tenantID, "tenant_id": tenantID,
"department": "DX", "department": "DX",
"customAttr": "kept", "customAttr": "kept",
@@ -101,7 +101,7 @@ func TestMapKratosIdentityToLocalUserPreservesArchivedStatus(t *testing.T) {
user := MapKratosIdentityToLocalUser(KratosIdentity{ user := MapKratosIdentityToLocalUser(KratosIdentity{
ID: "00000000-0000-0000-0000-000000000201", ID: "00000000-0000-0000-0000-000000000201",
State: domain.UserStatusArchived, State: domain.UserStatusArchived,
Traits: map[string]interface{}{ Traits: map[string]any{
"email": "archived@example.com", "email": "archived@example.com",
"name": "Archived User", "name": "Archived User",
}, },

View File

@@ -737,7 +737,7 @@ type WorksmobileUserPatchPayload struct {
DomainID int64 `json:"domainId"` DomainID int64 `json:"domainId"`
Email string `json:"email,omitempty"` Email string `json:"email,omitempty"`
UserExternalKey string `json:"userExternalKey,omitempty"` UserExternalKey string `json:"userExternalKey,omitempty"`
UserName WorksmobileUserName `json:"userName,omitempty"` UserName WorksmobileUserName `json:"userName"`
CellPhone string `json:"cellPhone,omitempty"` CellPhone string `json:"cellPhone,omitempty"`
EmployeeNumber string `json:"employeeNumber,omitempty"` EmployeeNumber string `json:"employeeNumber,omitempty"`
AliasEmails []string `json:"aliasEmails,omitempty"` AliasEmails []string `json:"aliasEmails,omitempty"`

View File

@@ -6,6 +6,7 @@ import (
"testing" "testing"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gorm.io/gorm"
) )
func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *testing.T) { func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *testing.T) {
@@ -1260,3 +1261,7 @@ func (f *fakeWorksmobileUserRepo) IsLoginIDTaken(ctx context.Context, loginID st
func (f *fakeWorksmobileUserRepo) FindTenantIDByLoginID(ctx context.Context, loginID string) (string, error) { func (f *fakeWorksmobileUserRepo) FindTenantIDByLoginID(ctx context.Context, loginID string) (string, error) {
return "", nil return "", nil
} }
func (f *fakeWorksmobileUserRepo) DB() *gorm.DB {
return nil
}

View File

@@ -33,7 +33,7 @@ func MaskSensitiveJSON(data []byte) []byte {
return data return data
} }
var obj interface{} var obj any
if err := json.Unmarshal(data, &obj); err != nil { if err := json.Unmarshal(data, &obj); err != nil {
// Not a JSON object/array, return as is // Not a JSON object/array, return as is
return data return data
@@ -48,10 +48,10 @@ func MaskSensitiveJSON(data []byte) []byte {
return result return result
} }
func maskValue(v interface{}) interface{} { func maskValue(v any) any {
switch val := v.(type) { switch val := v.(type) {
case map[string]interface{}: case map[string]any:
newMap := make(map[string]interface{}, len(val)) newMap := make(map[string]any, len(val))
for k, v := range val { for k, v := range val {
if isSensitive(k) { if isSensitive(k) {
newMap[k] = "*****" newMap[k] = "*****"
@@ -60,8 +60,8 @@ func maskValue(v interface{}) interface{} {
} }
} }
return newMap return newMap
case []interface{}: case []any:
newArr := make([]interface{}, len(val)) newArr := make([]any, len(val))
for i, v := range val { for i, v := range val {
newArr[i] = maskValue(v) newArr[i] = maskValue(v)
} }

View File

@@ -4,6 +4,8 @@ import (
"baron-sso-backend/internal/domain" "baron-sso-backend/internal/domain"
"crypto/rand" "crypto/rand"
"fmt" "fmt"
"slices"
"strings"
) )
const ( const (
@@ -111,13 +113,7 @@ func GeneratePasswordWithPolicy(policy *domain.PasswordPolicy) (string, error) {
if additionalTypes > 0 { if additionalTypes > 0 {
pool := make([]string, 0, len(selected)) pool := make([]string, 0, len(selected))
for _, cat := range selected { for _, cat := range selected {
isRequired := false isRequired := slices.Contains(required, cat)
for _, req := range required {
if req == cat {
isRequired = true
break
}
}
if !isRequired { if !isRequired {
pool = append(pool, cat) pool = append(pool, cat)
} }
@@ -150,12 +146,12 @@ func GeneratePasswordWithPolicy(policy *domain.PasswordPolicy) (string, error) {
passwordRunes = append(passwordRunes, ch) passwordRunes = append(passwordRunes, ch)
} }
combined := "" var combined strings.Builder
for _, charset := range selected { for _, charset := range selected {
combined += charset combined.WriteString(charset)
} }
for len(passwordRunes) < minLength { for len(passwordRunes) < minLength {
ch, err := randomChar(combined) ch, err := randomChar(combined.String())
if err != nil { if err != nil {
return "", err return "", err
} }

View File

@@ -8,7 +8,7 @@ import (
) )
// ValidateIDPCompatibility checks if the provided IDP supports all required fields defined in the BrokerUser model. // ValidateIDPCompatibility checks if the provided IDP supports all required fields defined in the BrokerUser model.
func ValidateIDPCompatibility(brokerModel interface{}, idp domain.IdentityProvider) error { func ValidateIDPCompatibility(brokerModel any, idp domain.IdentityProvider) error {
metadata, err := idp.GetMetadata() metadata, err := idp.GetMetadata()
if err != nil { if err != nil {
return fmt.Errorf("failed to fetch metadata from IDP %s: %w", idp.Name(), err) return fmt.Errorf("failed to fetch metadata from IDP %s: %w", idp.Name(), err)
@@ -20,12 +20,12 @@ func ValidateIDPCompatibility(brokerModel interface{}, idp domain.IdentityProvid
} }
t := reflect.TypeOf(brokerModel) t := reflect.TypeOf(brokerModel)
if t.Kind() == reflect.Ptr { if t.Kind() == reflect.Pointer {
t = t.Elem() t = t.Elem()
} }
for i := 0; i < t.NumField(); i++ { for field := range t.Fields() {
field := t.Field(i) field := field
// Check "required" tag // Check "required" tag
isRequired := field.Tag.Get("required") == "true" isRequired := field.Tag.Get("required") == "true"
@@ -47,8 +47,8 @@ func ValidateIDPCompatibility(brokerModel interface{}, idp domain.IdentityProvid
if fieldName == "attributes" { if fieldName == "attributes" {
reqKeys := field.Tag.Get("required_keys") reqKeys := field.Tag.Get("required_keys")
if reqKeys != "" { if reqKeys != "" {
keys := strings.Split(reqKeys, ",") keys := strings.SplitSeq(reqKeys, ",")
for _, key := range keys { for key := range keys {
key = strings.TrimSpace(key) key = strings.TrimSpace(key)
if !supportedMap[key] { if !supportedMap[key] {
return fmt.Errorf("IDP %s does not support required custom attribute: %s", idp.Name(), key) return fmt.Errorf("IDP %s does not support required custom attribute: %s", idp.Name(), key)

View File

@@ -188,6 +188,9 @@ active = "Active"
blocked = "Blocked" blocked = "Blocked"
failure = "Failure" failure = "Failure"
inactive = "Inactive" inactive = "Inactive"
new = "New"
ok = "Ok" ok = "Ok"
pending = "Pending" pending = "Pending"
success = "Success" success = "Success"
unchanged = "Unchanged"
updated = "Updated"

View File

@@ -188,6 +188,9 @@ active = "활성"
blocked = "차단됨" blocked = "차단됨"
failure = "실패" failure = "실패"
inactive = "비활성" inactive = "비활성"
new = "신규"
ok = "정상" ok = "정상"
pending = "준비 중" pending = "준비 중"
success = "성공" success = "성공"
unchanged = "동일"
updated = "수정"

View File

@@ -188,6 +188,9 @@ active = ""
blocked = "" blocked = ""
failure = "" failure = ""
inactive = "" inactive = ""
new = ""
ok = "" ok = ""
pending = "" pending = ""
success = "" success = ""
unchanged = ""
updated = ""

78
common/pnpm-lock.yaml generated
View File

@@ -63,8 +63,8 @@ importers:
specifier: ^3.3.0 specifier: ^3.3.0
version: 3.3.1(oidc-client-ts@3.5.0)(react@19.2.6) version: 3.3.1(oidc-client-ts@3.5.0)(react@19.2.6)
react-router-dom: react-router-dom:
specifier: ^6.28.2 specifier: ^7.15.1
version: 6.30.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) version: 7.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
tailwind-merge: tailwind-merge:
specifier: ^3.4.0 specifier: ^3.4.0
version: 3.6.0 version: 3.6.0
@@ -469,7 +469,7 @@ importers:
specifier: ^8.0.14 specifier: ^8.0.14
version: 8.0.14(@types/node@25.7.0)(jiti@1.21.7) version: 8.0.14(@types/node@25.7.0)(jiti@1.21.7)
vitest: vitest:
specifier: ^4.1.6 specifier: 4.1.6
version: 4.1.6(@types/node@25.7.0)(@vitest/coverage-v8@4.1.6)(jsdom@28.1.0)(vite@8.0.14(@types/node@25.7.0)(jiti@1.21.7)) version: 4.1.6(@types/node@25.7.0)(@vitest/coverage-v8@4.1.6)(jsdom@28.1.0)(vite@8.0.14(@types/node@25.7.0)(jiti@1.21.7))
packages: packages:
@@ -549,28 +549,24 @@ packages:
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@biomejs/cli-linux-arm64@2.4.16': '@biomejs/cli-linux-arm64@2.4.16':
resolution: {integrity: sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ==} resolution: {integrity: sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@biomejs/cli-linux-x64-musl@2.4.16': '@biomejs/cli-linux-x64-musl@2.4.16':
resolution: {integrity: sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg==} resolution: {integrity: sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@biomejs/cli-linux-x64@2.4.16': '@biomejs/cli-linux-x64@2.4.16':
resolution: {integrity: sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==} resolution: {integrity: sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@biomejs/cli-win32-arm64@2.4.16': '@biomejs/cli-win32-arm64@2.4.16':
resolution: {integrity: sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==} resolution: {integrity: sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==}
@@ -1095,10 +1091,6 @@ packages:
'@radix-ui/rect@1.1.1': '@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
'@remix-run/router@1.23.2':
resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==}
engines: {node: '>=14.0.0'}
'@rolldown/binding-android-arm64@1.0.0': '@rolldown/binding-android-arm64@1.0.0':
resolution: {integrity: sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==} resolution: {integrity: sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -1164,84 +1156,72 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-gnu@1.0.2': '@rolldown/binding-linux-arm64-gnu@1.0.2':
resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==} resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0': '@rolldown/binding-linux-arm64-musl@1.0.0':
resolution: {integrity: sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==} resolution: {integrity: sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@rolldown/binding-linux-arm64-musl@1.0.2': '@rolldown/binding-linux-arm64-musl@1.0.2':
resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==} resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.0': '@rolldown/binding-linux-ppc64-gnu@1.0.0':
resolution: {integrity: sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==} resolution: {integrity: sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@rolldown/binding-linux-ppc64-gnu@1.0.2': '@rolldown/binding-linux-ppc64-gnu@1.0.2':
resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==} resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.0': '@rolldown/binding-linux-s390x-gnu@1.0.0':
resolution: {integrity: sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==} resolution: {integrity: sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.2': '@rolldown/binding-linux-s390x-gnu@1.0.2':
resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==} resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.0': '@rolldown/binding-linux-x64-gnu@1.0.0':
resolution: {integrity: sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==} resolution: {integrity: sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.2': '@rolldown/binding-linux-x64-gnu@1.0.2':
resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==} resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0': '@rolldown/binding-linux-x64-musl@1.0.0':
resolution: {integrity: sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==} resolution: {integrity: sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@rolldown/binding-linux-x64-musl@1.0.2': '@rolldown/binding-linux-x64-musl@1.0.2':
resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==} resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0': '@rolldown/binding-openharmony-arm64@1.0.0':
resolution: {integrity: sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==} resolution: {integrity: sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==}
@@ -1940,28 +1920,24 @@ packages:
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0: lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0: lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0: lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0: lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -2219,13 +2195,6 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
react-router-dom@6.30.3:
resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
react-router-dom@7.15.0: react-router-dom@7.15.0:
resolution: {integrity: sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ==} resolution: {integrity: sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -2233,11 +2202,12 @@ packages:
react: '>=18' react: '>=18'
react-dom: '>=18' react-dom: '>=18'
react-router@6.30.3: react-router-dom@7.16.0:
resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==} resolution: {integrity: sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==}
engines: {node: '>=14.0.0'} engines: {node: '>=20.0.0'}
peerDependencies: peerDependencies:
react: '>=16.8' react: '>=18'
react-dom: '>=18'
react-router@7.15.0: react-router@7.15.0:
resolution: {integrity: sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==} resolution: {integrity: sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==}
@@ -2249,6 +2219,16 @@ packages:
react-dom: react-dom:
optional: true optional: true
react-router@7.16.0:
resolution: {integrity: sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: '>=18'
react-dom: '>=18'
peerDependenciesMeta:
react-dom:
optional: true
react-style-singleton@2.2.3: react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -3210,8 +3190,6 @@ snapshots:
'@radix-ui/rect@1.1.1': {} '@radix-ui/rect@1.1.1': {}
'@remix-run/router@1.23.2': {}
'@rolldown/binding-android-arm64@1.0.0': '@rolldown/binding-android-arm64@1.0.0':
optional: true optional: true
@@ -4199,23 +4177,17 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.14 '@types/react': 19.2.14
react-router-dom@6.30.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@remix-run/router': 1.23.2
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
react-router: 6.30.3(react@19.2.6)
react-router-dom@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): react-router-dom@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies: dependencies:
react: 19.2.6 react: 19.2.6
react-dom: 19.2.6(react@19.2.6) react-dom: 19.2.6(react@19.2.6)
react-router: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react-router: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-router@6.30.3(react@19.2.6): react-router-dom@7.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies: dependencies:
'@remix-run/router': 1.23.2
react: 19.2.6 react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
react-router: 7.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-router@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): react-router@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies: dependencies:
@@ -4225,6 +4197,14 @@ snapshots:
optionalDependencies: optionalDependencies:
react-dom: 19.2.6(react@19.2.6) react-dom: 19.2.6(react@19.2.6)
react-router@7.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
cookie: 1.1.1
react: 19.2.6
set-cookie-parser: 2.7.2
optionalDependencies:
react-dom: 19.2.6(react@19.2.6)
react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.6): react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.6):
dependencies: dependencies:
get-nonce: 1.0.1 get-nonce: 1.0.1

View File

@@ -1739,6 +1739,17 @@ additional = "Additional Affiliated/Manageable Tenants"
primary = "Representative Affiliated Tenant" primary = "Representative Affiliated Tenant"
title = "Affiliation & Organization Info" title = "Affiliation & Organization Info"
[ui.admin.users.field]
department = "Department"
grade = "Grade"
jobtitle = "Job Title"
name = "Name"
phone = "Phone"
position = "Position"
role = "Role"
status = "Status"
tenant = "Tenant"
[ui.admin.users.list] [ui.admin.users.list]
add = "User Add" add = "User Add"
bulk_import = "Bulk Import" bulk_import = "Bulk Import"
@@ -2878,6 +2889,8 @@ title = "Login request status by company and app"
export = "Export" export = "Export"
[ui.admin.users.bulk] [ui.admin.users.bulk]
modified_fields = "Modified Fields:"
no_changes = "No changes"
permission_placeholder = "Select permission" permission_placeholder = "Select permission"
status_placeholder = "Select status" status_placeholder = "Select status"

View File

@@ -2203,6 +2203,17 @@ additional = "추가 소속/관리 테넌트"
primary = "대표 소속 테넌트" primary = "대표 소속 테넌트"
title = "소속 및 조직 정보" title = "소속 및 조직 정보"
[ui.admin.users.field]
department = "부서"
grade = "직급"
jobtitle = "직무"
name = "이름"
phone = "전화번호"
position = "직책"
role = "역할"
status = "상태"
tenant = "테넌트"
[ui.admin.users.list] [ui.admin.users.list]
add = "사용자 추가" add = "사용자 추가"
bulk_import = "일괄 임포트" bulk_import = "일괄 임포트"
@@ -3300,6 +3311,8 @@ title = "회사별 앱별 로그인 요청 현황"
export = "내보내기" export = "내보내기"
[ui.admin.users.bulk] [ui.admin.users.bulk]
modified_fields = "수정 항목:"
no_changes = "변경 사항 없음"
permission_placeholder = "권한 선택" permission_placeholder = "권한 선택"
status_placeholder = "상태 선택" status_placeholder = "상태 선택"

View File

@@ -1782,8 +1782,10 @@ acknowledge_warning = ""
create_missing_tenant = "" create_missing_tenant = ""
do_move = "" do_move = ""
download_template = "" download_template = ""
modified_fields = ""
move_group = "" move_group = ""
move_title = "" move_title = ""
no_changes = ""
no_department = "" no_department = ""
schema_warning = "" schema_warning = ""
select_group = "" select_group = ""
@@ -2082,6 +2084,17 @@ additional = ""
primary = "" primary = ""
title = "" title = ""
[ui.admin.users.field]
department = ""
grade = ""
jobtitle = ""
name = ""
phone = ""
position = ""
role = ""
status = ""
tenant = ""
[ui.admin.users.list] [ui.admin.users.list]
add = "" add = ""
bulk_import = "" bulk_import = ""

View File

@@ -1,93 +0,0 @@
# 멀티 IDP 아키텍처 및 마이그레이션 전략
본 문서는 Primary IDP 변경(예: Descope → Hydra/Authentik) 시 시스템의 유연성을 보장하기 위한 아키텍처 설계 전략을 기술합니다.
## 핵심 전략
1. **Broker 패턴 고도화**: 백엔드가 모든 인증 요청을 중계하며, 클라이언트는 특정 IDP에 종속되지 않습니다.
2. **추상화 계층 (Abstraction Layer)**: 비즈니스 로직은 `IdentityProvider` 인터페이스에만 의존하며, 구체적인 구현체(Descope, Hydra 등)는 알 필요가 없습니다.
3. **식별자 분리 (Identifier Decoupling)**: 외부 IDP의 `sub` 값과 내부 시스템의 `user_id`를 분리하여 매핑합니다.
## 상세 설계
### 1. IdentityProvider 인터페이스 확장
단순 메타데이터 조회를 넘어, 인증의 핵심 라이프사이클을 모두 포함하도록 인터페이스를 확장해야 합니다.
```go
type IdentityProvider interface {
// 메타데이터 및 스키마 검증
Name() string
GetMetadata() (*IDPMetadata, error)
// 핵심 인증/인가 기능
SignUp(ctx context.Context, user *BrokerUser, password string) (*AuthResult, error)
Login(ctx context.Context, loginID, password string) (*AuthResult, error)
VerifyToken(ctx context.Context, token string) (*TokenInfo, error)
// 사용자 관리
GetUser(ctx context.Context, id string) (*BrokerUser, error)
UpdateUser(ctx context.Context, user *BrokerUser) error
DeleteUser(ctx context.Context, id string) error
}
```
### 2. Provider Factory 패턴
설정(`config.yaml` 또는 환경변수)에 따라 사용할 IDP 구현체를 런타임에 결정합니다.
```go
func NewIdentityProvider(config Config) (domain.IdentityProvider, error) {
switch config.IDPType {
case "descope":
return service.NewDescopeProvider(config.Descope)
case "hydra":
return service.NewHydraProvider(config.Hydra)
case "authentik":
return service.NewAuthentikProvider(config.Authentik)
default:
return nil, fmt.Errorf("unsupported IDP type: %s", config.IDPType)
}
}
```
### 3. 식별자 매핑 (ID Mapping)
IDP 변경 시 가장 큰 문제는 사용자 고유 ID(`sub`)가 바뀌는 것입니다. 이를 해결하기 위해 Baron SSO 내부 전용 UUID를 사용하고 매핑 테이블을 둡니다.
* **User Table (Baron Internal)**: `id` (UUID), `email`, ...
* **IDP Link Table**: `baron_user_id`, `idp_provider` (e.g., descope), `idp_subject_id` (e.g., U12345)
## 구현 예시 (Descope)
현재 단계에서 즉시 사용 가능한 `DescopeProvider`의 구현 예시입니다.
```go
// DescopeProvider는 IdentityProvider 인터페이스를 구현합니다.
type DescopeProvider struct {
Client *client.DescopeClient
}
func (d *DescopeProvider) SignUp(ctx context.Context, user *domain.BrokerUser, password string) (*domain.AuthResult, error) {
// BrokerUser를 Descope User 객체로 변환
descopeUser := &descope.User{
Name: user.Name,
Email: user.Email,
Phone: user.PhoneNumber,
CustomAttributes: user.Attributes,
}
// SDK 호출
authInfo, err := d.Client.Auth.Password().SignUp(ctx, user.Email, descopeUser, password)
if err != nil {
return nil, err\n }
// 결과 반환
return &domain.AuthResult{
UserID: authInfo.User.UserID,
Token: authInfo.SessionToken.JWT,
}, nil
}
```
## 향후 지원 후보 (Candidates)
* **Ory Hydra**: 자체적인 OAuth2/OIDC 서버 구축이 필요할 때 적합. 높은 커스터마이징 가능.
* **Authentik**: 오픈소스 IDP로, 설치형(Self-hosted) 환경에서 강력한 기능을 제공.