diff --git a/adminfront/src/features/tenants/routes/TenantListPage.test.ts b/adminfront/src/features/tenants/routes/TenantListPage.test.ts
index 34597320..36b6b125 100644
--- a/adminfront/src/features/tenants/routes/TenantListPage.test.ts
+++ b/adminfront/src/features/tenants/routes/TenantListPage.test.ts
@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi";
import {
- filterTenantViewRowsBySearch,
filterTenantsByScope,
+ filterTenantViewRowsBySearch,
getTenantSearchMatchIds,
getTenantViewRows,
resolveTenantSelectionIds,
@@ -107,7 +107,8 @@ describe("TenantListPage tenant list helpers", () => {
true,
);
- expect(filterTenantViewRowsBySearch(treeRows, "team-1").map((row) => row.id))
- .toEqual(["team-1"]);
+ expect(
+ filterTenantViewRowsBySearch(treeRows, "team-1").map((row) => row.id),
+ ).toEqual(["team-1"]);
});
});
diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx
index 8789299a..54485d24 100644
--- a/adminfront/src/features/tenants/routes/TenantListPage.tsx
+++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx
@@ -106,8 +106,8 @@ import {
type TenantImportResolution,
} from "../utils/tenantCsvImport";
import {
- filterTenantViewRowsBySearch,
filterTenantsByScope,
+ filterTenantViewRowsBySearch,
getTenantSearchMatchIds,
getTenantViewRows,
resolveTenantSelectionIds,
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index 0cb2c550..e5c74185 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -837,6 +837,7 @@ func main() {
dev.Get("/users", devHandler.SearchUsers)
dev.Get("/clients", devHandler.ListClients)
dev.Post("/clients", devHandler.CreateClient)
+ dev.Put("/clients/:id/users/me/metadata", devHandler.SelfUpdateRPUserMetadata)
dev.Get("/clients/:id/users/:userId/metadata", devHandler.GetRPUserMetadata)
dev.Put("/clients/:id/users/:userId/metadata", devHandler.UpsertRPUserMetadata)
dev.Get("/clients/:id", devHandler.GetClient)
diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go
index 03f2e456..50fd5498 100644
--- a/backend/internal/handler/dev_handler.go
+++ b/backend/internal/handler/dev_handler.go
@@ -573,6 +573,30 @@ func (h *DevHandler) canManageClientRelations(c *fiber.Ctx, profile *domain.User
return canAccessClientByLegacyScope(profile, summary)
}
+func (h *DevHandler) canManageRPUserMetadata(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary) bool {
+ if profile == nil {
+ return false
+ }
+ if normalizeUserRole(profile.Role) == domain.RoleSuperAdmin {
+ return true
+ }
+ return h.canOperateClientByPermit(c, profile, summary, "manage")
+}
+
+func (h *DevHandler) canSelfUpdateRPUserMetadata(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary) bool {
+ if profile == nil {
+ return false
+ }
+ if normalizeUserRole(profile.Role) == domain.RoleSuperAdmin {
+ return true
+ }
+ if h.Keto == nil {
+ return true
+ }
+ allowed, err := h.checkProfileKetoPermission(c, profile, "RelyingParty", summary.ID, "access")
+ return err == nil && allowed
+}
+
func (h *DevHandler) auditClientIDsByPermit(c *fiber.Ctx, profile *domain.UserProfileResponse, clientFilter string) map[string]struct{} {
ids := make(map[string]struct{})
if profile == nil || h.Hydra == nil {
@@ -1612,7 +1636,7 @@ func (h *DevHandler) UpsertRPUserMetadata(c *fiber.Ctx) error {
if err != nil {
return errorJSON(c, fiber.StatusNotFound, "client not found")
}
- if !h.canManageClientRelations(c, profile, summary) {
+ if !h.canManageRPUserMetadata(c, profile, summary) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permission to update client metadata")
}
@@ -1645,6 +1669,73 @@ func (h *DevHandler) UpsertRPUserMetadata(c *fiber.Ctx) error {
return c.JSON(row)
}
+func (h *DevHandler) SelfUpdateRPUserMetadata(c *fiber.Ctx) error {
+ clientID := strings.TrimSpace(c.Params("id"))
+ if clientID == "" {
+ return errorJSON(c, fiber.StatusBadRequest, "client id is required")
+ }
+ if h.RPUserMetadataRepo == nil {
+ return errorJSON(c, fiber.StatusServiceUnavailable, "rp user metadata repository unavailable")
+ }
+
+ profile := h.getCurrentProfile(c)
+ if profile == nil || strings.TrimSpace(profile.ID) == "" {
+ return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
+ }
+
+ summary, err := h.loadClientSummary(c.Context(), clientID)
+ if err != nil {
+ return errorJSON(c, fiber.StatusNotFound, "client not found")
+ }
+ if !h.canSelfUpdateRPUserMetadata(c, profile, summary) {
+ return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permission to update own client metadata")
+ }
+
+ var req struct {
+ Metadata map[string]any `json:"metadata"`
+ }
+ if err := c.BodyParser(&req); err != nil {
+ return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
+ }
+ if req.Metadata == nil {
+ req.Metadata = map[string]any{}
+ }
+
+ filteredMetadata, err := filterSelfWritableRPUserMetadata(req.Metadata, summary.Metadata)
+ if err != nil {
+ return errorJSON(c, fiber.StatusForbidden, err.Error())
+ }
+ normalizedMetadata, err := normalizeRPUserMetadataForClient(filteredMetadata, summary.Metadata)
+ if err != nil {
+ return errorJSON(c, fiber.StatusBadRequest, err.Error())
+ }
+
+ userID := strings.TrimSpace(profile.ID)
+ mergedMetadata := domain.JSONMap{}
+ if existing, err := h.RPUserMetadataRepo.Get(c.Context(), clientID, userID); err == nil && existing != nil {
+ for key, value := range existing.Metadata {
+ mergedMetadata[key] = value
+ }
+ }
+ for key, value := range normalizedMetadata {
+ mergedMetadata[key] = value
+ }
+
+ row := &domain.RPUserMetadata{
+ ClientID: clientID,
+ UserID: userID,
+ Metadata: mergedMetadata,
+ }
+ if err := h.RPUserMetadataRepo.Upsert(c.Context(), row); err != nil {
+ return errorJSON(c, fiber.StatusInternalServerError, err.Error())
+ }
+ if err := h.syncRPUserMetadataToKratos(c.Context(), userID, clientID, mergedMetadata); err != nil {
+ return errorJSON(c, fiber.StatusInternalServerError, err.Error())
+ }
+
+ return c.JSON(row)
+}
+
func (h *DevHandler) syncRPUserMetadataToKratos(ctx context.Context, userID string, clientID string, metadata domain.JSONMap) error {
if h == nil || h.KratosAdmin == nil {
return nil
@@ -1769,6 +1860,33 @@ func normalizeRPUserMetadataForClient(metadata map[string]any, clientMetadata ma
return normalized, nil
}
+func filterSelfWritableRPUserMetadata(metadata map[string]any, clientMetadata map[string]any) (map[string]any, error) {
+ schemas, err := rpUserMetadataClaimSchemas(clientMetadata)
+ if err != nil {
+ return nil, err
+ }
+
+ filtered := map[string]any{}
+ for rawKey, rawValue := range metadata {
+ key := strings.TrimSpace(rawKey)
+ if key == "" || isEmptyRPUserMetadataValue(rawValue) {
+ continue
+ }
+ if strings.HasSuffix(key, "_permissions") {
+ return nil, fmt.Errorf("rp user metadata permission cannot be updated by user: %s", key)
+ }
+ schema, ok := schemas[key]
+ if !ok {
+ return nil, fmt.Errorf("rp user metadata claim is not configured: %s", key)
+ }
+ if normalizeCustomClaimPermission(schema.WritePermission) != "user_and_admin" {
+ return nil, fmt.Errorf("rp user metadata claim is admin only: %s", key)
+ }
+ filtered[key] = rawValue
+ }
+ return filtered, nil
+}
+
func rpUserMetadataClaimSchemas(clientMetadata map[string]any) (map[string]rpUserMetadataClaimSchema, error) {
rawClaims, ok := clientMetadata[domain.MetadataIDTokenClaims]
if !ok || rawClaims == nil {
diff --git a/backend/internal/handler/dev_handler_rp_metadata_test.go b/backend/internal/handler/dev_handler_rp_metadata_test.go
index 8c62f4d7..a1ba4bdc 100644
--- a/backend/internal/handler/dev_handler_rp_metadata_test.go
+++ b/backend/internal/handler/dev_handler_rp_metadata_test.go
@@ -125,6 +125,100 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
repo.AssertExpectations(t)
}
+func TestDevHandler_RPUserMetadataAdminUpsertRequiresRPManage(t *testing.T) {
+ transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
+ if r.URL.Path == "/clients/client-1" {
+ return httpJSONAny(r, http.StatusOK, map[string]any{
+ "client_id": "client-1",
+ "client_name": "Client One",
+ "metadata": map[string]any{
+ "tenant_id": "tenant-1",
+ "id_token_claims": []map[string]any{
+ {
+ "namespace": "rp_claims",
+ "key": "approvalLevel",
+ "valueType": "text",
+ "value": "A",
+ "readPermission": "user_and_admin",
+ "writePermission": "user_and_admin",
+ },
+ },
+ },
+ }), nil
+ }
+ return httpJSONAny(r, http.StatusNotFound, nil), nil
+ })
+
+ t.Run("tenant grant does not allow rp user metadata admin upsert", func(t *testing.T) {
+ repo := new(devMockRPUserMetadataRepo)
+ repo.On("Upsert", mock.Anything, mock.AnythingOfType("*domain.RPUserMetadata")).Return(nil).Maybe()
+ keto := new(devMockKetoService)
+ keto.On("CheckPermission", mock.Anything, "User:operator-1", "RelyingParty", "client-1", "manage").Return(false, nil)
+ keto.On("CheckPermission", mock.Anything, "User:operator-1", "Tenant", "tenant-1", "grant_dev_permissions").Return(true, nil).Maybe()
+ h := &DevHandler{
+ Hydra: &service.HydraAdminService{
+ AdminURL: "http://hydra.test",
+ HTTPClient: &http.Client{Transport: transport},
+ },
+ Keto: keto,
+ RPUserMetadataRepo: repo,
+ }
+ app := fiber.New()
+ app.Use(func(c *fiber.Ctx) error {
+ c.Locals("user_profile", &domain.UserProfileResponse{ID: "operator-1", Role: domain.RoleUser})
+ return c.Next()
+ })
+ app.Put("/api/v1/dev/clients/:id/users/:userId/metadata", h.UpsertRPUserMetadata)
+
+ body, _ := json.Marshal(map[string]any{
+ "metadata": map[string]any{"approvalLevel": "B"},
+ })
+ req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1/users/user-1/metadata", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ resp, _ := app.Test(req, -1)
+
+ require.Equal(t, http.StatusForbidden, resp.StatusCode)
+ repo.AssertNotCalled(t, "Upsert", mock.Anything, mock.Anything)
+ keto.AssertExpectations(t)
+ })
+
+ t.Run("rp manage allows rp user metadata admin upsert", func(t *testing.T) {
+ repo := new(devMockRPUserMetadataRepo)
+ repo.On("Upsert", mock.Anything, mock.MatchedBy(func(row *domain.RPUserMetadata) bool {
+ return row.ClientID == "client-1" &&
+ row.UserID == "user-1" &&
+ row.Metadata["approvalLevel"] == "B"
+ })).Return(nil).Once()
+ keto := new(devMockKetoService)
+ keto.On("CheckPermission", mock.Anything, "User:operator-1", "RelyingParty", "client-1", "manage").Return(true, nil)
+ h := &DevHandler{
+ Hydra: &service.HydraAdminService{
+ AdminURL: "http://hydra.test",
+ HTTPClient: &http.Client{Transport: transport},
+ },
+ Keto: keto,
+ RPUserMetadataRepo: repo,
+ }
+ app := fiber.New()
+ app.Use(func(c *fiber.Ctx) error {
+ c.Locals("user_profile", &domain.UserProfileResponse{ID: "operator-1", Role: domain.RoleUser})
+ return c.Next()
+ })
+ app.Put("/api/v1/dev/clients/:id/users/:userId/metadata", h.UpsertRPUserMetadata)
+
+ body, _ := json.Marshal(map[string]any{
+ "metadata": map[string]any{"approvalLevel": "B"},
+ })
+ req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1/users/user-1/metadata", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ resp, _ := app.Test(req, -1)
+
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+ repo.AssertExpectations(t)
+ keto.AssertExpectations(t)
+ })
+}
+
func TestDevHandler_RPUserMetadataMirrorsToKratosTraits(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/clients/client-1" {
@@ -201,6 +295,130 @@ func TestDevHandler_RPUserMetadataMirrorsToKratosTraits(t *testing.T) {
kratos.AssertExpectations(t)
}
+func TestDevHandler_SelfUpdateRPUserMetadataHonorsWritePermission(t *testing.T) {
+ transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
+ if r.URL.Path == "/clients/client-1" {
+ return httpJSONAny(r, http.StatusOK, map[string]any{
+ "client_id": "client-1",
+ "client_name": "Client One",
+ "metadata": map[string]any{
+ "tenant_id": "tenant-1",
+ "id_token_claims": []map[string]any{
+ {
+ "namespace": "rp_claims",
+ "key": "approvalLevel",
+ "valueType": "text",
+ "value": "A",
+ "readPermission": "user_and_admin",
+ "writePermission": "user_and_admin",
+ },
+ {
+ "namespace": "rp_claims",
+ "key": "internalRank",
+ "valueType": "text",
+ "value": "S",
+ "readPermission": "admin_only",
+ "writePermission": "admin_only",
+ },
+ },
+ },
+ }), nil
+ }
+ return httpJSONAny(r, http.StatusNotFound, nil), nil
+ })
+
+ t.Run("rejects admin_only claim", func(t *testing.T) {
+ repo := new(devMockRPUserMetadataRepo)
+ repo.On("Upsert", mock.Anything, mock.AnythingOfType("*domain.RPUserMetadata")).Return(nil).Maybe()
+ h := &DevHandler{
+ Hydra: &service.HydraAdminService{
+ AdminURL: "http://hydra.test",
+ HTTPClient: &http.Client{Transport: transport},
+ },
+ RPUserMetadataRepo: repo,
+ }
+ app := fiber.New()
+ app.Use(func(c *fiber.Ctx) error {
+ c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleUser})
+ return c.Next()
+ })
+ app.Put("/api/v1/dev/clients/:id/users/me/metadata", h.SelfUpdateRPUserMetadata)
+
+ body, _ := json.Marshal(map[string]any{
+ "metadata": map[string]any{"internalRank": "A"},
+ })
+ req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1/users/me/metadata", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ resp, _ := app.Test(req, -1)
+
+ require.Equal(t, http.StatusForbidden, resp.StatusCode)
+ repo.AssertNotCalled(t, "Upsert", mock.Anything, mock.Anything)
+ })
+
+ t.Run("allows user_and_admin claim for self", func(t *testing.T) {
+ repo := new(devMockRPUserMetadataRepo)
+ repo.On("Get", mock.Anything, "client-1", "user-1").Return(&domain.RPUserMetadata{
+ ClientID: "client-1",
+ UserID: "user-1",
+ Metadata: domain.JSONMap{
+ "internalRank": "S",
+ "internalRank_permissions": map[string]any{
+ "readPermission": "admin_only",
+ "writePermission": "admin_only",
+ },
+ },
+ }, nil).Once()
+ repo.On("Upsert", mock.Anything, mock.MatchedBy(func(row *domain.RPUserMetadata) bool {
+ return row.ClientID == "client-1" &&
+ row.UserID == "user-1" &&
+ row.Metadata["approvalLevel"] == "B" &&
+ row.Metadata["internalRank"] == "S"
+ })).Return(nil).Once()
+ kratos := new(MockKratosAdmin)
+ kratos.On("GetIdentity", mock.Anything, "user-1").Return(&service.KratosIdentity{
+ ID: "user-1",
+ State: "active",
+ Traits: map[string]any{
+ "email": "user@example.com",
+ },
+ }, nil).Once()
+ var capturedTraits map[string]any
+ kratos.On("UpdateIdentity", mock.Anything, "user-1", mock.Anything, "active").Run(func(args mock.Arguments) {
+ capturedTraits = args.Get(2).(map[string]any)
+ }).Return(&service.KratosIdentity{ID: "user-1", State: "active", Traits: map[string]any{}}, nil).Once()
+ h := &DevHandler{
+ Hydra: &service.HydraAdminService{
+ AdminURL: "http://hydra.test",
+ HTTPClient: &http.Client{Transport: transport},
+ },
+ KratosAdmin: kratos,
+ IdentityWriter: service.NewIdentityWriteService(kratos, nil),
+ RPUserMetadataRepo: repo,
+ }
+ app := fiber.New()
+ app.Use(func(c *fiber.Ctx) error {
+ c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleUser})
+ return c.Next()
+ })
+ app.Put("/api/v1/dev/clients/:id/users/me/metadata", h.SelfUpdateRPUserMetadata)
+
+ body, _ := json.Marshal(map[string]any{
+ "metadata": map[string]any{"approvalLevel": "B"},
+ })
+ req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1/users/me/metadata", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ resp, _ := app.Test(req, -1)
+
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+ rpClaims := capturedTraits["rp_custom_claims"].(map[string]any)
+ clientClaims := rpClaims["client-1"].(domain.JSONMap)
+ require.Equal(t, "B", clientClaims["approvalLevel"])
+ require.Equal(t, "S", clientClaims["internalRank"])
+ repo.AssertExpectations(t)
+ kratos.AssertExpectations(t)
+ })
+}
+
func TestDevHandler_RPUserMetadataRejectsUndefinedClaimKey(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/clients/client-1" {
diff --git a/common/config/biome.base.json b/common/config/biome.base.json
index 8067b5d1..57dac5f8 100644
--- a/common/config/biome.base.json
+++ b/common/config/biome.base.json
@@ -4,6 +4,11 @@
"enabled": true,
"indentStyle": "space"
},
+ "css": {
+ "parser": {
+ "tailwindDirectives": true
+ }
+ },
"linter": {
"enabled": true,
"rules": {
@@ -25,6 +30,7 @@
"**",
"!**/dist/**",
"!**/.vite/**",
+ "!**/.pnpm-store/**",
"!**/node_modules/**",
"!**/coverage/**",
"!**/tsconfig*.json",
diff --git a/common/core/components/audit/AuditLogTable.test.tsx b/common/core/components/audit/AuditLogTable.test.tsx
index d5e6e368..ff5335eb 100644
--- a/common/core/components/audit/AuditLogTable.test.tsx
+++ b/common/core/components/audit/AuditLogTable.test.tsx
@@ -1,5 +1,5 @@
-import { act } from "react-dom/test-utils";
import { createRoot, type Root } from "react-dom/client";
+import { act } from "react-dom/test-utils";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CommonAuditLog } from "../../audit";
import { AuditLogTable } from "./AuditLogTable";
@@ -128,8 +128,12 @@ describe("AuditLogTable", () => {
expect(loadMoreButton).toBeTruthy();
await act(async () => {
- actorCopyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
- targetCopyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ actorCopyButton?.dispatchEvent(
+ new MouseEvent("click", { bubbles: true }),
+ );
+ targetCopyButton?.dispatchEvent(
+ new MouseEvent("click", { bubbles: true }),
+ );
expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
diff --git a/common/core/components/audit/AuditLogTable.tsx b/common/core/components/audit/AuditLogTable.tsx
index 42c4371e..4f4a7098 100644
--- a/common/core/components/audit/AuditLogTable.tsx
+++ b/common/core/components/audit/AuditLogTable.tsx
@@ -1,8 +1,8 @@
import { ChevronDown, ChevronUp, Copy } from "lucide-react";
import * as React from "react";
import {
- getCommonBadgeClasses,
type CommonBadgeVariant,
+ getCommonBadgeClasses,
} from "../../../ui/badge";
import { getCommonButtonClasses } from "../../../ui/button";
import {
@@ -90,7 +90,12 @@ export function AuditLogTable({
-
+
{t("ui.common.audit.table.time", "Time")}
@@ -122,7 +127,12 @@ export function AuditLogTable({
return (
-
+
{date}
{time}
@@ -154,12 +164,22 @@ export function AuditLogTable({
) : null}
-
+
{actionLabel}
-
+
{targetLabel}
{targetLabel !== "-" ? (
@@ -192,7 +212,9 @@ export function AuditLogTable({
{log.status}
-
+