From 2ecb600d8eebafe9f055bf3617784b6e309e745f Mon Sep 17 00:00:00 2001 From: rashmithachamikara Date: Sun, 2 Aug 2026 17:55:37 +0530 Subject: [PATCH 1/7] Add RBAC support to portal backend --- portal/backend/.env.example | 7 +- portal/backend/README.md | 2 + portal/backend/internal/me/handler.go | 25 +++++++ portal/backend/internal/me/handler_test.go | 74 +++++++++++++++++++ portal/backend/internal/me/init.go | 1 + .../backend/internal/system/auth/manager.go | 28 ++++++- .../internal/system/auth/manager_test.go | 67 ++++++++++++++++- portal/backend/internal/system/auth/scopes.go | 12 +++ .../internal/system/auth/scopes_test.go | 24 ++++++ .../backend/internal/system/config/config.go | 10 +++ .../internal/system/config/config_test.go | 18 +++++ portal/backend/openapi/portal-backend.yaml | 42 +++++++++++ .../integration/auth_integration_test.go | 27 +++++++ .../integration/proxy_integration_test.go | 4 + 14 files changed, 335 insertions(+), 6 deletions(-) create mode 100644 portal/backend/internal/me/handler_test.go diff --git a/portal/backend/.env.example b/portal/backend/.env.example index c95f6104..75b23fca 100644 --- a/portal/backend/.env.example +++ b/portal/backend/.env.example @@ -30,8 +30,9 @@ BFF_PROXY__ALLOWED_PASSTHROUGH_METHODS=["GET","POST","PUT","DELETE"] # Placeholder identity mode for local development (must be false in production) BFF_PROXY__PLACEHOLDER_MODE_ENABLED=false -BFF_PROXY__PLACEHOLDER_USER_ID= -BFF_PROXY__PLACEHOLDER_ORG_ID= +BFF_PROXY__PLACEHOLDER_USER_ID=user1@example.com +BFF_PROXY__PLACEHOLDER_ORG_ID=ORG-001 +BFF_PROXY__PLACEHOLDER_SCOPES=portal:consents:read:self portal:consents:write:self # OIDC confidential-client authentication BFF_AUTH__ENABLED=false @@ -42,7 +43,7 @@ BFF_AUTH__CLIENT_SECRET= BFF_AUTH__PORTAL_URL=http://localhost:5173/consents BFF_AUTH__REDIRECT_URI=http://localhost:8080/auth/callback BFF_AUTH__POST_LOGOUT_REDIRECT_URI=http://localhost:5173/ -BFF_AUTH__SCOPES=openid email profile portal:consents:read:self portal:consents:write:self portal:elements:read portal:elements:write portal:purposes:read portal:purposes:write +BFF_AUTH__SCOPES=openid email profile portal:consents:read:self portal:consents:write:self portal:consents:read:any portal:consents:write:any portal:elements:read portal:elements:write portal:purposes:read portal:purposes:write BFF_AUTH__RESOURCE_AUDIENCE= BFF_AUTH__ALLOWED_SIGNING_ALGORITHMS=RS256 BFF_AUTH__HTTP_TIMEOUT=5s diff --git a/portal/backend/README.md b/portal/backend/README.md index a7091320..62b42fb6 100644 --- a/portal/backend/README.md +++ b/portal/backend/README.md @@ -128,6 +128,8 @@ Common error codes: - `BFF_PROXY__PLACEHOLDER_MODE_ENABLED=true` is blocked when `BFF_ENV=production` - `BFF_PROXY__PLACEHOLDER_USER_ID` must be empty if placeholder mode is disabled - `BFF_PROXY__PLACEHOLDER_ORG_ID` must be empty if placeholder mode is disabled +- `BFF_PROXY__PLACEHOLDER_SCOPES` is a space-separated list of portal scope. Must be empty if placeholder mode is disabled +- Empty placeholder scopes are allowed and produce an authenticated principal with no portal permissions ## AI Instructions diff --git a/portal/backend/internal/me/handler.go b/portal/backend/internal/me/handler.go index bfea6921..151b1d98 100644 --- a/portal/backend/internal/me/handler.go +++ b/portal/backend/internal/me/handler.go @@ -29,6 +29,7 @@ import ( "strings" "github.com/wso2/openfgc/portal/backend/internal/proxy" + "github.com/wso2/openfgc/portal/backend/internal/system/auth" "github.com/wso2/openfgc/portal/backend/internal/system/config" systemcontext "github.com/wso2/openfgc/portal/backend/internal/system/context" ) @@ -46,6 +47,12 @@ type errorResponse struct { Message string `json:"message"` } +type currentUserResponse struct { + UserID string `json:"userId"` + OrganizationID string `json:"organizationId"` + Scopes []string `json:"scopes"` +} + var errRequestBodyTooLarge = errors.New("request body too large") var consentIDPattern = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) @@ -58,6 +65,24 @@ func NewHandler(cfg config.ProxyConfig) (*Handler, error) { return &Handler{svc: svc, cfg: cfg}, nil } +// CurrentUser handles GET /me. +func (h *Handler) CurrentUser(w http.ResponseWriter, r *http.Request) { + principal, ok := systemcontext.PrincipalFromContext(r.Context()) + if !ok { + writeJSONError(w, http.StatusUnauthorized, "UNAUTHORIZED", "authentication required") + return + } + + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(currentUserResponse{ + UserID: principal.UserID, + OrganizationID: principal.OrgID, + Scopes: auth.GrantedPortalScopes(principal.Scopes), + }) +} + // Consents handles GET /me/consents. func (h *Handler) Consents(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { diff --git a/portal/backend/internal/me/handler_test.go b/portal/backend/internal/me/handler_test.go new file mode 100644 index 00000000..d9dc89ef --- /dev/null +++ b/portal/backend/internal/me/handler_test.go @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package me + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/wso2/openfgc/portal/backend/internal/system/auth" + systemcontext "github.com/wso2/openfgc/portal/backend/internal/system/context" +) + +func TestCurrentUserReturnsCanonicalPortalScopes(t *testing.T) { + principal := systemcontext.Principal{ + UserID: "user-1", + OrgID: "org-1", + Scopes: map[string]struct{}{ + "openid": {}, + auth.ScopePurposesWrite: {}, + auth.ScopeConsentsReadSelf: {}, + }, + } + req := httptest.NewRequest(http.MethodGet, "/me", nil) + req = req.WithContext(systemcontext.WithPrincipal(req.Context(), principal)) + recorder := httptest.NewRecorder() + + (&Handler{}).CurrentUser(recorder, req) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK) + } + if got := recorder.Header().Get("Cache-Control"); got != "no-store" { + t.Fatalf("Cache-Control = %q, want no-store", got) + } + var response currentUserResponse + if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.UserID != "user-1" || response.OrganizationID != "org-1" { + t.Fatalf("unexpected identity: %+v", response) + } + wantScopes := []string{auth.ScopeConsentsReadSelf, auth.ScopePurposesWrite} + if !reflect.DeepEqual(response.Scopes, wantScopes) { + t.Fatalf("scopes = %v, want %v", response.Scopes, wantScopes) + } +} + +func TestCurrentUserRequiresPrincipal(t *testing.T) { + recorder := httptest.NewRecorder() + (&Handler{}).CurrentUser(recorder, httptest.NewRequest(http.MethodGet, "/me", nil)) + + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusUnauthorized) + } +} diff --git a/portal/backend/internal/me/init.go b/portal/backend/internal/me/init.go index 8b53bb85..116a3e07 100644 --- a/portal/backend/internal/me/init.go +++ b/portal/backend/internal/me/init.go @@ -32,6 +32,7 @@ func Initialize(mux *http.ServeMux, cfg config.Config, authManager *auth.Manager return err } + mux.Handle("GET /me", authManager.Require(http.HandlerFunc(handler.CurrentUser))) mux.Handle("GET /me/consents", authManager.Require(http.HandlerFunc(handler.Consents), auth.ScopeConsentsReadSelf)) mux.Handle("GET /me/consents/{consentId}", authManager.Require(http.HandlerFunc(handler.ConsentByID), auth.ScopeConsentsReadSelf)) mux.Handle("POST /me/consents/{consentId}/approve", authManager.Require(http.HandlerFunc(handler.ConsentApprove), auth.ScopeConsentsWriteSelf)) diff --git a/portal/backend/internal/system/auth/manager.go b/portal/backend/internal/system/auth/manager.go index 5db7d00e..2ec59a7d 100644 --- a/portal/backend/internal/system/auth/manager.go +++ b/portal/backend/internal/system/auth/manager.go @@ -23,6 +23,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "log/slog" "net/http" "strings" @@ -53,6 +54,11 @@ type Manager struct { func NewManager(ctx context.Context, cfg config.AuthConfig, proxyCfg config.ProxyConfig, log *slog.Logger) (*Manager, error) { m := &Manager{cfg: cfg, proxyCfg: proxyCfg, log: log} if !cfg.Enabled { + if proxyCfg.PlaceholderModeEnabled { + if err := validatePlaceholderScopes(proxyCfg.PlaceholderScopes); err != nil { + return nil, err + } + } return m, nil } if err := validateConfiguredScopes(cfg.Scopes); err != nil { @@ -110,6 +116,24 @@ func validateConfiguredScopesWithPrefix(configured []string, prefix string, port return nil } +func validatePlaceholderScopes(configured []string) error { + canonical := make(map[string]struct{}, len(AllPortalScopes)) + for _, scope := range AllPortalScopes { + canonical[scope] = struct{}{} + } + seen := make(map[string]struct{}, len(configured)) + for _, scope := range configured { + if _, ok := canonical[scope]; !ok { + return fmt.Errorf("proxy placeholder scopes contain unknown portal scope %q", scope) + } + if _, duplicate := seen[scope]; duplicate { + return fmt.Errorf("proxy placeholder scopes contain duplicate scope %q", scope) + } + seen[scope] = struct{}{} + } + return nil +} + // RegisterRoutes registers browser-facing authentication endpoints. func (m *Manager) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /auth/login", m.Login) @@ -164,8 +188,8 @@ func (m *Manager) authenticate(r *http.Request) (systemcontext.Principal, error) if userID == "" || orgID == "" { return systemcontext.Principal{}, errInvalidCredentials } - scopes := make(map[string]struct{}, len(AllPortalScopes)) - for _, scope := range AllPortalScopes { + scopes := make(map[string]struct{}, len(m.proxyCfg.PlaceholderScopes)) + for _, scope := range m.proxyCfg.PlaceholderScopes { scopes[scope] = struct{}{} } return systemcontext.Principal{UserID: userID, OrgID: orgID, Scopes: scopes}, nil diff --git a/portal/backend/internal/system/auth/manager_test.go b/portal/backend/internal/system/auth/manager_test.go index 149a4bf4..906de933 100644 --- a/portal/backend/internal/system/auth/manager_test.go +++ b/portal/backend/internal/system/auth/manager_test.go @@ -38,6 +38,7 @@ func TestPlaceholderModePopulatesPrincipalContext(t *testing.T) { PlaceholderModeEnabled: true, PlaceholderUserID: " user-1 ", PlaceholderOrgID: " org-1 ", + PlaceholderScopes: []string{ScopeConsentsReadSelf, ScopeElementsRead}, }, slog.New(slog.NewTextHandler(io.Discard, nil)), ) @@ -56,11 +57,14 @@ func TestPlaceholderModePopulatesPrincipalContext(t *testing.T) { if principal.UserID != "user-1" || principal.OrgID != "org-1" { t.Errorf("unexpected placeholder principal: %#v", principal) } - for _, scope := range AllPortalScopes { + for _, scope := range []string{ScopeConsentsReadSelf, ScopeElementsRead} { if _, ok := principal.Scopes[scope]; !ok { t.Errorf("placeholder principal missing scope %q", scope) } } + if _, ok := principal.Scopes[ScopeConsentsWriteSelf]; ok { + t.Errorf("placeholder principal unexpectedly has unconfigured scope %q", ScopeConsentsWriteSelf) + } w.WriteHeader(http.StatusNoContent) })).ServeHTTP(recorder, request) @@ -68,3 +72,64 @@ func TestPlaceholderModePopulatesPrincipalContext(t *testing.T) { t.Fatalf("unexpected response status: %d", recorder.Code) } } + +func TestPlaceholderModeRejectsInvalidScopes(t *testing.T) { + tests := []struct { + name string + scopes []string + }{ + {name: "unknown", scopes: []string{"portal:unknown"}}, + {name: "duplicate", scopes: []string{ScopeElementsRead, ScopeElementsRead}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewManager( + context.Background(), + config.AuthConfig{}, + config.ProxyConfig{ + PlaceholderModeEnabled: true, + PlaceholderUserID: "user-1", + PlaceholderOrgID: "org-1", + PlaceholderScopes: tt.scopes, + }, + slog.New(slog.NewTextHandler(io.Discard, nil)), + ) + if err == nil { + t.Fatal("expected invalid placeholder scopes to be rejected") + } + }) + } +} + +func TestPlaceholderModeAllowsNoScopes(t *testing.T) { + manager, err := NewManager( + context.Background(), + config.AuthConfig{}, + config.ProxyConfig{ + PlaceholderModeEnabled: true, + PlaceholderUserID: "user-1", + PlaceholderOrgID: "org-1", + }, + slog.New(slog.NewTextHandler(io.Discard, nil)), + ) + if err != nil { + t.Fatalf("new manager: %v", err) + } + + recorder := httptest.NewRecorder() + manager.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + principal, ok := systemcontext.PrincipalFromContext(r.Context()) + if !ok { + t.Fatal("expected placeholder principal") + } + if len(principal.Scopes) != 0 { + t.Fatalf("expected no placeholder scopes, got %#v", principal.Scopes) + } + w.WriteHeader(http.StatusNoContent) + })).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/protected", nil)) + + if recorder.Code != http.StatusNoContent { + t.Fatalf("unexpected response status: %d", recorder.Code) + } +} diff --git a/portal/backend/internal/system/auth/scopes.go b/portal/backend/internal/system/auth/scopes.go index 50fb6a57..808f9d16 100644 --- a/portal/backend/internal/system/auth/scopes.go +++ b/portal/backend/internal/system/auth/scopes.go @@ -47,6 +47,18 @@ var AllPortalScopes = []string{ ScopePurposesRead, ScopePurposesWrite, } +// GrantedPortalScopes returns the canonical portal scopes present in granted, +// preserving the stable order of AllPortalScopes. +func GrantedPortalScopes(granted map[string]struct{}) []string { + result := make([]string, 0, len(AllPortalScopes)) + for _, scope := range AllPortalScopes { + if _, ok := granted[scope]; ok { + result = append(result, scope) + } + } + return result +} + type apiScopePolicy struct { method, pattern, scope string } diff --git a/portal/backend/internal/system/auth/scopes_test.go b/portal/backend/internal/system/auth/scopes_test.go index 6e620511..605a414b 100644 --- a/portal/backend/internal/system/auth/scopes_test.go +++ b/portal/backend/internal/system/auth/scopes_test.go @@ -68,6 +68,25 @@ func TestCanonicalScopesAreUniqueAndPrefixed(t *testing.T) { } } +func TestGrantedPortalScopesFiltersAndPreservesCanonicalOrder(t *testing.T) { + granted := map[string]struct{}{ + "openid": {}, + ScopePurposesWrite: {}, + ScopeConsentsReadSelf: {}, + "portal:future:scope": {}, + } + want := []string{ScopeConsentsReadSelf, ScopePurposesWrite} + got := GrantedPortalScopes(granted) + if len(got) != len(want) { + t.Fatalf("got %v; want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v; want %v", got, want) + } + } +} + func TestEveryAPIRouteHasCanonicalScopePolicy(t *testing.T) { tests := []struct { method string @@ -192,6 +211,11 @@ func TestOpenAPIScopesMatchCanonicalRoutePolicies(t *testing.T) { continue } want = policy + case route == "/me": + if len(scopes) != 0 { + t.Errorf("%s %s documents %v; want no business scope", upperMethod, route, scopes) + } + continue case strings.HasPrefix(route, "/me/"): if upperMethod == "GET" { want = ScopeConsentsReadSelf diff --git a/portal/backend/internal/system/config/config.go b/portal/backend/internal/system/config/config.go index 314b6c65..d5d33b1d 100644 --- a/portal/backend/internal/system/config/config.go +++ b/portal/backend/internal/system/config/config.go @@ -113,6 +113,7 @@ type ProxyConfig struct { PlaceholderModeEnabled bool `koanf:"placeholder_mode_enabled"` PlaceholderUserID string `koanf:"placeholder_user_id"` PlaceholderOrgID string `koanf:"placeholder_org_id"` + PlaceholderScopes []string `koanf:"placeholder_scopes"` AllowedPassthrough []string `koanf:"allowed_passthrough_methods"` } @@ -167,6 +168,9 @@ func Load() (*Config, error) { if rawScopes := os.Getenv("BFF_AUTH__SCOPES"); rawScopes != "" { cfg.Auth.Scopes = strings.Fields(rawScopes) } + if rawScopes := os.Getenv("BFF_PROXY__PLACEHOLDER_SCOPES"); rawScopes != "" { + cfg.Proxy.PlaceholderScopes = strings.Fields(rawScopes) + } if rawAlgorithms := os.Getenv("BFF_AUTH__ALLOWED_SIGNING_ALGORITHMS"); rawAlgorithms != "" { cfg.Auth.AllowedSigningAlgorithms = ParseCSV(rawAlgorithms) } @@ -310,6 +314,9 @@ func setDefaults(k *koanf.Koanf) error { if err := k.Set("proxy.placeholder_org_id", ""); err != nil { return err } + if err := k.Set("proxy.placeholder_scopes", []string{}); err != nil { + return err + } if err := k.Set("proxy.allowed_passthrough_methods", []string{"GET", "POST", "PUT", "DELETE"}); err != nil { return err } @@ -387,6 +394,9 @@ func validate(cfg Config) error { if !cfg.Proxy.PlaceholderModeEnabled && cfg.Proxy.PlaceholderOrgID != "" { return fmt.Errorf("proxy.placeholder_org_id must be empty when placeholder mode is disabled") } + if !cfg.Proxy.PlaceholderModeEnabled && len(cfg.Proxy.PlaceholderScopes) != 0 { + return fmt.Errorf("proxy.placeholder_scopes must be empty when placeholder mode is disabled") + } if len(cfg.Proxy.AllowedPassthrough) == 0 { return fmt.Errorf("proxy.allowed_passthrough_methods must not be empty") } diff --git a/portal/backend/internal/system/config/config_test.go b/portal/backend/internal/system/config/config_test.go index 047f106a..dbdbb225 100644 --- a/portal/backend/internal/system/config/config_test.go +++ b/portal/backend/internal/system/config/config_test.go @@ -21,6 +21,7 @@ package config import ( "os" "path/filepath" + "reflect" "strings" "testing" "time" @@ -130,6 +131,7 @@ func TestPlaceholderValuesRejectedWhenModeDisabled(t *testing.T) { }{ {name: "user id", envName: "BFF_PROXY__PLACEHOLDER_USER_ID", errText: "proxy.placeholder_user_id must be empty"}, {name: "org id", envName: "BFF_PROXY__PLACEHOLDER_ORG_ID", errText: "proxy.placeholder_org_id must be empty"}, + {name: "scopes", envName: "BFF_PROXY__PLACEHOLDER_SCOPES", errText: "proxy.placeholder_scopes must be empty"}, } for _, tt := range tests { @@ -148,6 +150,22 @@ func TestPlaceholderValuesRejectedWhenModeDisabled(t *testing.T) { } } +func TestPlaceholderScopesEnv(t *testing.T) { + t.Setenv("BFF_PROXY__PLACEHOLDER_MODE_ENABLED", "true") + t.Setenv("BFF_PROXY__PLACEHOLDER_USER_ID", "local-user") + t.Setenv("BFF_PROXY__PLACEHOLDER_ORG_ID", "local-org") + t.Setenv("BFF_PROXY__PLACEHOLDER_SCOPES", "portal:consents:read:self portal:elements:read") + + cfg, err := Load() + if err != nil { + t.Fatalf("expected config to load, got error: %v", err) + } + want := []string{"portal:consents:read:self", "portal:elements:read"} + if !reflect.DeepEqual(cfg.Proxy.PlaceholderScopes, want) { + t.Fatalf("unexpected placeholder scopes: %#v", cfg.Proxy.PlaceholderScopes) + } +} + func TestAllowedPassthroughMethodsEnvJSON(t *testing.T) { t.Setenv("BFF_PROXY__ALLOWED_PASSTHROUGH_METHODS", `["get", "put"]`) diff --git a/portal/backend/openapi/portal-backend.yaml b/portal/backend/openapi/portal-backend.yaml index 8cc17f39..9b575028 100644 --- a/portal/backend/openapi/portal-backend.yaml +++ b/portal/backend/openapi/portal-backend.yaml @@ -107,6 +107,28 @@ paths: schema: { $ref: '#/components/schemas/LogoutResponse' } '401': { $ref: '#/components/responses/AuthError' } + /me: + get: + tags: [User] + summary: Get the authenticated portal user + description: >- + Returns identity and canonical portal authorization scopes derived from + the locally validated access token. OIDC-only scopes are omitted. + security: + - PortalOAuthDocumentation: [] + AccessTokenPart2: [] + responses: + '200': + description: Authenticated portal user. + headers: + Cache-Control: + description: Prevents authenticated user data from being cached. + schema: { type: string, example: no-store } + content: + application/json: + schema: { $ref: '#/components/schemas/CurrentUser' } + '401': { $ref: '#/components/responses/AuthError' } + /me/consents: get: tags: [User] @@ -617,6 +639,26 @@ components: ElementID: { in: path, name: elementId, required: true, schema: { type: string } } Version: { in: path, name: version, required: true, schema: { type: string, pattern: '^v[1-9][0-9]*$' } } schemas: + CurrentUser: + type: object + required: [userId, organizationId, scopes] + properties: + userId: { type: string } + organizationId: { type: string } + scopes: + type: array + uniqueItems: true + items: + type: string + enum: + - portal:consents:read:self + - portal:consents:write:self + - portal:consents:read:any + - portal:consents:write:any + - portal:elements:read + - portal:elements:write + - portal:purposes:read + - portal:purposes:write LogoutResponse: type: object required: [logoutUrl] diff --git a/portal/backend/tests/integration/auth_integration_test.go b/portal/backend/tests/integration/auth_integration_test.go index 894d517c..58a3fbb5 100644 --- a/portal/backend/tests/integration/auth_integration_test.go +++ b/portal/backend/tests/integration/auth_integration_test.go @@ -254,6 +254,33 @@ func TestOIDCAuthLifecycleThroughAssembledBFF(t *testing.T) { fixture := newAuthIntegrationFixture(t, strings.Join(auth.AllPortalScopes, " ")) cookies := fixture.login(t) + currentUserRequest, _ := http.NewRequest(http.MethodGet, fixture.bff.URL+"/me", nil) + addAuthIntegrationAccess(currentUserRequest, fixture.cfg.Auth, cookies) + currentUserResponse, err := fixture.client.Do(currentUserRequest) + if err != nil { + t.Fatalf("current user request: %v", err) + } + if currentUserResponse.StatusCode != http.StatusOK { + _ = currentUserResponse.Body.Close() + t.Fatalf("current user returned %d", currentUserResponse.StatusCode) + } + var currentUserPayload struct { + UserID string `json:"userId"` + OrganizationID string `json:"organizationId"` + Scopes []string `json:"scopes"` + } + if err := json.NewDecoder(currentUserResponse.Body).Decode(¤tUserPayload); err != nil { + _ = currentUserResponse.Body.Close() + t.Fatalf("decode current user: %v", err) + } + _ = currentUserResponse.Body.Close() + if currentUserPayload.UserID != "integration-user" || currentUserPayload.OrganizationID != "integration-org" { + t.Fatalf("unexpected current user: %+v", currentUserPayload) + } + if len(currentUserPayload.Scopes) != len(auth.AllPortalScopes) { + t.Fatalf("current user scopes = %v", currentUserPayload.Scopes) + } + apiRequest, _ := http.NewRequest(http.MethodGet, fixture.bff.URL+"/api/consents?limit=10", nil) addAuthIntegrationAccess(apiRequest, fixture.cfg.Auth, cookies) apiResponse, err := fixture.client.Do(apiRequest) diff --git a/portal/backend/tests/integration/proxy_integration_test.go b/portal/backend/tests/integration/proxy_integration_test.go index 0029b50f..2cc97c73 100644 --- a/portal/backend/tests/integration/proxy_integration_test.go +++ b/portal/backend/tests/integration/proxy_integration_test.go @@ -29,6 +29,7 @@ import ( "testing" "time" + "github.com/wso2/openfgc/portal/backend/internal/system/auth" "github.com/wso2/openfgc/portal/backend/internal/system/config" ) @@ -44,6 +45,7 @@ func newPortalServer(t *testing.T, upstreamURL string, configure func(*config.Co cfg.Proxy.PlaceholderModeEnabled = true cfg.Proxy.PlaceholderUserID = "user@example.com" cfg.Proxy.PlaceholderOrgID = "ORG-001" + cfg.Proxy.PlaceholderScopes = append([]string(nil), auth.AllPortalScopes...) if configure != nil { configure(cfg) } @@ -503,6 +505,7 @@ func TestProxyErrorAndBodyLimits(t *testing.T) { cfg.Proxy.PlaceholderModeEnabled = true cfg.Proxy.PlaceholderUserID = "user@example.com" cfg.Proxy.PlaceholderOrgID = "ORG-001" + cfg.Proxy.PlaceholderScopes = append([]string(nil), auth.AllPortalScopes...) h, _ := newIntegrationHandler(*cfg) req := httptest.NewRequest(http.MethodPost, "/api/consents", nil) req.Body = failingReadCloser{} @@ -525,6 +528,7 @@ func TestMeEndpointsReturn503WhenPlaceholderModeDisabled(t *testing.T) { cfg.Proxy.PlaceholderModeEnabled = false cfg.Proxy.PlaceholderUserID = "" cfg.Proxy.PlaceholderOrgID = "" + cfg.Proxy.PlaceholderScopes = nil }) defer bff.Close() From e0dbdc2dcdb532f2a3d1b9102642928747abfe44 Mon Sep 17 00:00:00 2001 From: rashmithachamikara Date: Sun, 2 Aug 2026 18:11:11 +0530 Subject: [PATCH 2/7] Add RBAC support to portal frontend --- .github/workflows/portal-frontend-ci.yml | 2 + portal/frontend/package.json | 3 +- portal/frontend/pnpm-lock.yaml | 62 +++++-- portal/frontend/src/App.tsx | 153 ++++++++++++++++-- .../src/__tests__/AppSidebar.test.tsx | 45 ++++-- .../src/__tests__/Authorization.test.tsx | 84 ++++++++++ .../src/__tests__/ConsentDetailsPage.test.tsx | 21 ++- .../__tests__/ConsentRegistryPage.test.tsx | 28 ++-- .../src/__tests__/CurrentUserApi.test.ts | 58 +++++++ .../src/__tests__/PortalScopes.test.ts | 53 ++++++ .../src/__tests__/PurposeFormDialog.test.tsx | 15 +- .../__tests__/TestAuthorizationProvider.tsx | 41 +++++ .../layout/main-layout/MainLayout.tsx | 2 + .../components/layout/sidebar/AppSidebar.tsx | 80 +++++---- .../features/auth/AuthorizationProvider.tsx | 55 +++++++ .../src/features/auth/NoAccessPage.tsx | 49 ++++++ .../frontend/src/features/auth/ScopeGuard.tsx | 32 ++++ .../src/features/auth/api/currentUserApi.ts | 49 ++++++ .../src/features/auth/authorizationContext.ts | 32 ++++ .../src/features/auth/authorizationRoutes.ts | 31 ++++ .../auth/hooks/useCurrentUserQuery.ts | 32 ++++ .../src/features/auth/useAuthorization.ts | 28 ++++ .../features/catalog/ElementDetailsPage.tsx | 46 +++--- .../src/features/catalog/ElementListPage.tsx | 20 ++- .../features/catalog/PurposeDetailsPage.tsx | 46 +++--- .../src/features/catalog/PurposeListPage.tsx | 20 ++- .../catalog/components/ElementTypeChip.tsx | 15 +- .../consent-registry/ConsentDetailsPage.tsx | 10 +- .../hooks/useConsentQueries.ts | 27 +++- .../src/features/dashboard/DashboardPage.tsx | 15 +- .../hooks/useDashboardConsentsQuery.ts | 15 +- .../frontend/src/i18n/resources/en/common.ts | 7 + portal/frontend/src/types/auth.ts | 25 +++ portal/frontend/src/utils/portalScopes.ts | 36 +++++ 34 files changed, 1083 insertions(+), 154 deletions(-) create mode 100644 portal/frontend/src/__tests__/Authorization.test.tsx create mode 100644 portal/frontend/src/__tests__/CurrentUserApi.test.ts create mode 100644 portal/frontend/src/__tests__/PortalScopes.test.ts create mode 100644 portal/frontend/src/__tests__/TestAuthorizationProvider.tsx create mode 100644 portal/frontend/src/features/auth/AuthorizationProvider.tsx create mode 100644 portal/frontend/src/features/auth/NoAccessPage.tsx create mode 100644 portal/frontend/src/features/auth/ScopeGuard.tsx create mode 100644 portal/frontend/src/features/auth/api/currentUserApi.ts create mode 100644 portal/frontend/src/features/auth/authorizationContext.ts create mode 100644 portal/frontend/src/features/auth/authorizationRoutes.ts create mode 100644 portal/frontend/src/features/auth/hooks/useCurrentUserQuery.ts create mode 100644 portal/frontend/src/features/auth/useAuthorization.ts create mode 100644 portal/frontend/src/types/auth.ts create mode 100644 portal/frontend/src/utils/portalScopes.ts diff --git a/.github/workflows/portal-frontend-ci.yml b/.github/workflows/portal-frontend-ci.yml index 3a5851d6..54e4e35e 100644 --- a/.github/workflows/portal-frontend-ci.yml +++ b/.github/workflows/portal-frontend-ci.yml @@ -4,11 +4,13 @@ on: pull_request: paths: - 'portal/frontend/**' + - 'portal/backend/openapi/portal-backend.yaml' - '.github/workflows/portal-frontend-ci.yml' push: branches: [ main ] paths: - 'portal/frontend/**' + - 'portal/backend/openapi/portal-backend.yaml' - '.github/workflows/portal-frontend-ci.yml' jobs: diff --git a/portal/frontend/package.json b/portal/frontend/package.json index 844f8bb2..fcf6e232 100644 --- a/portal/frontend/package.json +++ b/portal/frontend/package.json @@ -54,7 +54,8 @@ "typescript": "5.9.3", "typescript-eslint": "8.57.1", "vite": "7.3.5", - "vitest": "3.2.6" + "vitest": "3.2.6", + "yaml": "2.8.1" }, "pnpm": { "overrides": { diff --git a/portal/frontend/pnpm-lock.yaml b/portal/frontend/pnpm-lock.yaml index 25574e23..69b2bc89 100644 --- a/portal/frontend/pnpm-lock.yaml +++ b/portal/frontend/pnpm-lock.yaml @@ -68,10 +68,10 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: 5.2.0 - version: 5.2.0(vite@7.3.5(@types/node@24.12.0)(lightningcss@1.32.0)) + version: 5.2.0(vite@7.3.5(@types/node@24.12.0)(lightningcss@1.32.0)(yaml@2.8.1)) '@vitest/coverage-v8': specifier: 3.2.6 - version: 3.2.6(vitest@3.2.6(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0)) + version: 3.2.6(vitest@3.2.6(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0)(yaml@2.8.1)) eslint: specifier: 9.39.4 version: 9.39.4 @@ -116,10 +116,13 @@ importers: version: 8.57.1(eslint@9.39.4)(typescript@5.9.3) vite: specifier: 7.3.5 - version: 7.3.5(@types/node@24.12.0)(lightningcss@1.32.0) + version: 7.3.5(@types/node@24.12.0)(lightningcss@1.32.0)(yaml@2.8.1) vitest: specifier: 3.2.6 - version: 3.2.6(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0) + version: 3.2.6(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0)(yaml@2.8.1) + yaml: + specifier: 2.8.1 + version: 2.8.1 packages: @@ -777,66 +780,79 @@ packages: resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.62.2': resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.62.2': resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.62.2': resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.62.2': resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.62.2': resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.62.2': resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.62.2': resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.62.2': resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.62.2': resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.62.2': resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.62.2': resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.62.2': resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.62.2': resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} @@ -2026,24 +2042,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -2812,6 +2832,11 @@ packages: resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} engines: {node: '>= 6'} + yaml@2.8.1: + resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} + engines: {node: '>= 14.6'} + hasBin: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -3684,7 +3709,7 @@ snapshots: '@typescript-eslint/types': 8.57.1 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@5.2.0(vite@7.3.5(@types/node@24.12.0)(lightningcss@1.32.0))': + '@vitejs/plugin-react@5.2.0(vite@7.3.5(@types/node@24.12.0)(lightningcss@1.32.0)(yaml@2.8.1))': dependencies: '@babel/core': 7.29.6 '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.6) @@ -3692,11 +3717,11 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.5(@types/node@24.12.0)(lightningcss@1.32.0) + vite: 7.3.5(@types/node@24.12.0)(lightningcss@1.32.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@3.2.6(vitest@3.2.6(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0))': + '@vitest/coverage-v8@3.2.6(vitest@3.2.6(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0)(yaml@2.8.1))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -3711,7 +3736,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.6(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0) + vitest: 3.2.6(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -3723,13 +3748,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.6(vite@7.3.5(@types/node@24.12.0)(lightningcss@1.32.0))': + '@vitest/mocker@3.2.6(vite@7.3.5(@types/node@24.12.0)(lightningcss@1.32.0)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.5(@types/node@24.12.0)(lightningcss@1.32.0) + vite: 7.3.5(@types/node@24.12.0)(lightningcss@1.32.0)(yaml@2.8.1) '@vitest/pretty-format@3.2.6': dependencies: @@ -5610,13 +5635,13 @@ snapshots: dependencies: react: 19.2.4 - vite-node@3.2.4(@types/node@24.12.0)(lightningcss@1.32.0): + vite-node@3.2.4(@types/node@24.12.0)(lightningcss@1.32.0)(yaml@2.8.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.5(@types/node@24.12.0)(lightningcss@1.32.0) + vite: 7.3.5(@types/node@24.12.0)(lightningcss@1.32.0)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' - jiti @@ -5631,7 +5656,7 @@ snapshots: - tsx - yaml - vite@7.3.5(@types/node@24.12.0)(lightningcss@1.32.0): + vite@7.3.5(@types/node@24.12.0)(lightningcss@1.32.0)(yaml@2.8.1): dependencies: esbuild: 0.28.1 fdir: 6.5.0(picomatch@4.0.4) @@ -5643,12 +5668,13 @@ snapshots: '@types/node': 24.12.0 fsevents: 2.3.3 lightningcss: 1.32.0 + yaml: 2.8.1 - vitest@3.2.6(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0): + vitest@3.2.6(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0)(yaml@2.8.1): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.6 - '@vitest/mocker': 3.2.6(vite@7.3.5(@types/node@24.12.0)(lightningcss@1.32.0)) + '@vitest/mocker': 3.2.6(vite@7.3.5(@types/node@24.12.0)(lightningcss@1.32.0)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.7 '@vitest/runner': 3.2.6 '@vitest/snapshot': 3.2.6 @@ -5666,8 +5692,8 @@ snapshots: tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.5(@types/node@24.12.0)(lightningcss@1.32.0) - vite-node: 3.2.4(@types/node@24.12.0)(lightningcss@1.32.0) + vite: 7.3.5(@types/node@24.12.0)(lightningcss@1.32.0)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@24.12.0)(lightningcss@1.32.0)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.12.0 @@ -5779,6 +5805,8 @@ snapshots: yaml@1.10.3: {} + yaml@2.8.1: {} + yocto-queue@0.1.0: {} zod-validation-error@4.0.2(zod@4.4.3): diff --git a/portal/frontend/src/App.tsx b/portal/frontend/src/App.tsx index 08c4a23d..04a53ec7 100644 --- a/portal/frontend/src/App.tsx +++ b/portal/frontend/src/App.tsx @@ -16,7 +16,10 @@ * under the License. */ +import { Box, Button, CircularProgress, Stack, Typography } from '@wso2/oxygen-ui' +import { CircleAlert } from '@wso2/oxygen-ui-icons-react' import { useEffect } from 'react' +import { useTranslation } from 'react-i18next' import { Navigate, Route, Routes } from 'react-router-dom' import MainLayout from './components/layout/main-layout/MainLayout' import ElementDetailsPage from './features/catalog/ElementDetailsPage' @@ -26,22 +29,97 @@ import PurposeListPage from './features/catalog/PurposeListPage' import ConsentDetailsPage from './features/consent-registry/ConsentDetailsPage' import ConsentRegistryPage from './features/consent-registry/ConsentRegistryPage' import DashboardPage from './features/dashboard/DashboardPage' -import { isAuthenticated, login } from './utils/authClient' +import { AuthorizationProvider } from './features/auth/AuthorizationProvider' +import useAuthorization from './features/auth/useAuthorization' +import firstAuthorizedPath from './features/auth/authorizationRoutes' +import NoAccessPage from './features/auth/NoAccessPage' +import useCurrentUserQuery from './features/auth/hooks/useCurrentUserQuery' +import { isAuthEnabled, isAuthenticated, login } from './utils/authClient' +import { PORTAL_SCOPES, type PortalScope } from './utils/portalScopes' +import { APIError } from './utils/apiClient' function AuthenticationGate({ children, }: { children: React.JSX.Element }): React.JSX.Element | null { - const authenticated = isAuthenticated() + const { t } = useTranslation('common') + const cookieAuthenticated = isAuthenticated() + const currentUserQuery = useCurrentUserQuery(cookieAuthenticated) useEffect(() => { - if (!authenticated) { + if (!cookieAuthenticated) { login() } - }, [authenticated]) + }, [cookieAuthenticated]) - return authenticated ? children : null + if (!cookieAuthenticated) { + return null + } + + if (currentUserQuery.isPending) { + return ( + + + + ) + } + + if (currentUserQuery.isError || !currentUserQuery.data) { + if ( + isAuthEnabled() && + currentUserQuery.error instanceof APIError && + currentUserQuery.error.status === 401 + ) { + return null + } + return ( + + + + + ) + } + + return ( + {children} + ) +} + +function AuthorizedRoute({ + scope, + children, +}: { + scope: PortalScope + children: React.JSX.Element +}): React.JSX.Element { + const { currentUser, hasScope } = useAuthorization() + if (hasScope(scope)) { + return children + } + const fallback = firstAuthorizedPath(currentUser.scopes) + return fallback ? : +} + +function AuthorizedFallback(): React.JSX.Element { + const { currentUser } = useAuthorization() + const fallback = firstAuthorizedPath(currentUser.scopes) + return fallback ? : } function App(): React.JSX.Element { @@ -49,14 +127,63 @@ function App(): React.JSX.Element { }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> diff --git a/portal/frontend/src/__tests__/AppSidebar.test.tsx b/portal/frontend/src/__tests__/AppSidebar.test.tsx index bfb808ff..dfbde508 100644 --- a/portal/frontend/src/__tests__/AppSidebar.test.tsx +++ b/portal/frontend/src/__tests__/AppSidebar.test.tsx @@ -23,6 +23,8 @@ import { afterEach, describe, expect, it } from 'vitest' import { AcrylicOrangeTheme, CssBaseline, OxygenUIThemeProvider } from '@wso2/oxygen-ui' import AppSidebar from '../components/layout/sidebar/AppSidebar' import i18n from '../i18n/i18n' +import TestAuthorizationProvider from './TestAuthorizationProvider' +import { PORTAL_SCOPES } from '../utils/portalScopes' function LocationProbe(): React.JSX.Element { const location = useLocation() @@ -41,17 +43,19 @@ describe('AppSidebar', () => { - - - - - - } - /> - + + + + + + + } + /> + + , @@ -70,4 +74,23 @@ describe('AppSidebar', () => { expect(screen.getByText('/dashboard')).toBeInTheDocument() }) + + it('hides unauthorized items and empty categories', () => { + render( + + + + + + + + + , + ) + + expect(screen.getByText('Purposes')).toBeInTheDocument() + expect(screen.queryByText('Consent')).not.toBeInTheDocument() + expect(screen.queryByText('Dashboard')).not.toBeInTheDocument() + expect(screen.queryByText('Elements')).not.toBeInTheDocument() + }) }) diff --git a/portal/frontend/src/__tests__/Authorization.test.tsx b/portal/frontend/src/__tests__/Authorization.test.tsx new file mode 100644 index 00000000..f3e66e1e --- /dev/null +++ b/portal/frontend/src/__tests__/Authorization.test.tsx @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import useAuthorization from '../features/auth/useAuthorization' +import ScopeGuard from '../features/auth/ScopeGuard' +import { PORTAL_SCOPES } from '../utils/portalScopes' +import TestAuthorizationProvider from './TestAuthorizationProvider' +import firstAuthorizedPath from '../features/auth/authorizationRoutes' + +function AuthorizationProbe(): React.JSX.Element { + const { hasScope, hasAnyScope, hasAllScopes } = useAuthorization() + return ( + <> + {String(hasScope(PORTAL_SCOPES.ELEMENTS_READ))} + + {String(hasAnyScope([PORTAL_SCOPES.ELEMENTS_WRITE, PORTAL_SCOPES.CONSENTS_READ_SELF]))} + + + {String(hasAllScopes([PORTAL_SCOPES.ELEMENTS_READ, PORTAL_SCOPES.CONSENTS_READ_SELF]))} + + + ) +} + +describe('frontend authorization', () => { + it('selects the first authorized destination in stable navigation order', () => { + expect(firstAuthorizedPath([PORTAL_SCOPES.ELEMENTS_READ, PORTAL_SCOPES.PURPOSES_READ])).toBe( + '/purposes', + ) + expect(firstAuthorizedPath([PORTAL_SCOPES.CONSENTS_READ_SELF])).toBe('/dashboard') + expect(firstAuthorizedPath([])).toBeUndefined() + }) + + it('provides typed single, any, and all scope checks', () => { + render( + + + , + ) + + expect(screen.getAllByText('true')).toHaveLength(3) + }) + + it('renders guarded content only with the required scope', () => { + const { rerender } = render( + + + Write control + + , + ) + expect(screen.queryByText('Write control')).not.toBeInTheDocument() + + rerender( + + + Write control + + , + ) + expect(screen.getByText('Write control')).toBeInTheDocument() + }) +}) diff --git a/portal/frontend/src/__tests__/ConsentDetailsPage.test.tsx b/portal/frontend/src/__tests__/ConsentDetailsPage.test.tsx index 199da37a..a2a2833a 100644 --- a/portal/frontend/src/__tests__/ConsentDetailsPage.test.tsx +++ b/portal/frontend/src/__tests__/ConsentDetailsPage.test.tsx @@ -25,12 +25,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import ConsentDetailsPage from '../features/consent-registry/ConsentDetailsPage' import i18n from '../i18n/i18n' import type { ConsentStatusAuditItem } from '../types/consent' +import type { PortalScope } from '../utils/portalScopes' +import TestAuthorizationProvider from './TestAuthorizationProvider' +import { PORTAL_SCOPES } from '../utils/portalScopes' const fetchMock = vi.fn() function renderConsentDetailsPage( status: string, statusHistory: ConsentStatusAuditItem[] = [], + scopes: PortalScope[] = Object.values(PORTAL_SCOPES), ): void { vi.stubGlobal('fetch', fetchMock) fetchMock.mockResolvedValue({ @@ -64,9 +68,11 @@ function renderConsentDetailsPage( - - } /> - + + + } /> + + @@ -97,6 +103,15 @@ describe('ConsentDetailsPage lifecycle actions', () => { expect(screen.queryByRole('button', { name: 'Reject' })).not.toBeInTheDocument() }) + it('hides lifecycle actions without the consent write scope', async () => { + renderConsentDetailsPage('CREATED', [], [PORTAL_SCOPES.CONSENTS_READ_SELF]) + + expect(await screen.findByRole('heading', { name: 'Consent Details' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Approve' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Reject' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Revoke' })).not.toBeInTheDocument() + }) + it.each(['REJECTED', 'REVOKED', 'EXPIRED'])( 'shows no lifecycle action for %s consents', async (status) => { diff --git a/portal/frontend/src/__tests__/ConsentRegistryPage.test.tsx b/portal/frontend/src/__tests__/ConsentRegistryPage.test.tsx index 1ff7cb23..75feea49 100644 --- a/portal/frontend/src/__tests__/ConsentRegistryPage.test.tsx +++ b/portal/frontend/src/__tests__/ConsentRegistryPage.test.tsx @@ -24,6 +24,8 @@ import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-rou import { afterEach, describe, expect, it, vi } from 'vitest' import ConsentRegistryPage from '../features/consent-registry/ConsentRegistryPage' import i18n from '../i18n/i18n' +import TestAuthorizationProvider from './TestAuthorizationProvider' +import { PORTAL_SCOPES } from '../utils/portalScopes' const fetchMock = vi.fn() @@ -60,18 +62,20 @@ function renderConsentRegistryPage(queryClient: QueryClient, initialEntry = '/co - - - - - - - } - /> - + + + + + + + + } + /> + + diff --git a/portal/frontend/src/__tests__/CurrentUserApi.test.ts b/portal/frontend/src/__tests__/CurrentUserApi.test.ts new file mode 100644 index 00000000..901d0394 --- /dev/null +++ b/portal/frontend/src/__tests__/CurrentUserApi.test.ts @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { fetchCurrentUser } from '../features/auth/api/currentUserApi' +import { PORTAL_SCOPES } from '../utils/portalScopes' + +const apiClientMocks = vi.hoisted(() => ({ + apiRequest: vi.fn(), +})) + +vi.mock('../utils/apiClient', () => apiClientMocks) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('current-user API', () => { + it('returns a typed current user from GET /me', async () => { + apiClientMocks.apiRequest.mockResolvedValue({ + userId: 'user-1', + organizationId: 'org-1', + scopes: [PORTAL_SCOPES.CONSENTS_READ_SELF], + }) + + await expect(fetchCurrentUser()).resolves.toEqual({ + userId: 'user-1', + organizationId: 'org-1', + scopes: [PORTAL_SCOPES.CONSENTS_READ_SELF], + }) + expect(apiClientMocks.apiRequest).toHaveBeenCalledWith('/me', { method: 'GET' }) + }) + + it('rejects unknown scopes and malformed identity data', async () => { + apiClientMocks.apiRequest.mockResolvedValue({ + userId: 'user-1', + organizationId: '', + scopes: ['portal:unknown'], + }) + + await expect(fetchCurrentUser()).rejects.toThrow('invalid current-user response') + }) +}) diff --git a/portal/frontend/src/__tests__/PortalScopes.test.ts b/portal/frontend/src/__tests__/PortalScopes.test.ts new file mode 100644 index 00000000..9222ef3e --- /dev/null +++ b/portal/frontend/src/__tests__/PortalScopes.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' +import { PORTAL_SCOPES } from '../utils/portalScopes' + +function asRecord(value: unknown, name: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`${name} must be an object`) + } + return value as Record +} + +function documentedPortalScopes(): string[] { + const openAPIPath = path.resolve(process.cwd(), '../backend/openapi/portal-backend.yaml') + const document = asRecord(parse(readFileSync(openAPIPath, 'utf8')) as unknown, 'document') + const components = asRecord(document.components, 'components') + const securitySchemes = asRecord(components.securitySchemes, 'securitySchemes') + const portalOAuth = asRecord(securitySchemes.PortalOAuthDocumentation, 'PortalOAuthDocumentation') + const flows = asRecord(portalOAuth.flows, 'flows') + const authorizationCode = asRecord(flows.authorizationCode, 'authorizationCode') + return Object.keys(asRecord(authorizationCode.scopes, 'scopes')) +} + +describe('portal scope registry', () => { + it('exactly matches the backend OpenAPI scope registry', () => { + const frontendScopes = Object.values(PORTAL_SCOPES) + const documentedScopes = documentedPortalScopes() + + expect(frontendScopes.every((scope) => scope.trim().length > 0)).toBe(true) + expect(new Set(frontendScopes).size).toBe(frontendScopes.length) + expect(new Set(documentedScopes).size).toBe(documentedScopes.length) + expect([...frontendScopes].sort()).toEqual([...documentedScopes].sort()) + }) +}) diff --git a/portal/frontend/src/__tests__/PurposeFormDialog.test.tsx b/portal/frontend/src/__tests__/PurposeFormDialog.test.tsx index a0ee995b..615fe596 100644 --- a/portal/frontend/src/__tests__/PurposeFormDialog.test.tsx +++ b/portal/frontend/src/__tests__/PurposeFormDialog.test.tsx @@ -1,6 +1,19 @@ /* * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * Licensed under the Apache License, Version 2.0. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ import { cleanup, fireEvent, render, screen } from '@testing-library/react' diff --git a/portal/frontend/src/__tests__/TestAuthorizationProvider.tsx b/portal/frontend/src/__tests__/TestAuthorizationProvider.tsx new file mode 100644 index 00000000..fb331974 --- /dev/null +++ b/portal/frontend/src/__tests__/TestAuthorizationProvider.tsx @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { ReactNode } from 'react' +import { AuthorizationProvider } from '../features/auth/AuthorizationProvider' +import type { PortalScope } from '../utils/portalScopes' + +interface TestAuthorizationProviderProps { + children: ReactNode + scopes: PortalScope[] +} + +function TestAuthorizationProvider({ + children, + scopes, +}: TestAuthorizationProviderProps): React.JSX.Element { + return ( + + {children} + + ) +} + +export default TestAuthorizationProvider diff --git a/portal/frontend/src/components/layout/main-layout/MainLayout.tsx b/portal/frontend/src/components/layout/main-layout/MainLayout.tsx index 9c252e8a..23d6d10b 100644 --- a/portal/frontend/src/components/layout/main-layout/MainLayout.tsx +++ b/portal/frontend/src/components/layout/main-layout/MainLayout.tsx @@ -73,6 +73,8 @@ function MainLayout(): React.JSX.Element { width: '100%', maxWidth: 'none', flex: 1, + display: 'flex', + flexDirection: 'column', }} > diff --git a/portal/frontend/src/components/layout/sidebar/AppSidebar.tsx b/portal/frontend/src/components/layout/sidebar/AppSidebar.tsx index ae54db79..35b46749 100644 --- a/portal/frontend/src/components/layout/sidebar/AppSidebar.tsx +++ b/portal/frontend/src/components/layout/sidebar/AppSidebar.tsx @@ -20,6 +20,8 @@ import { Sidebar } from '@wso2/oxygen-ui' import { Blocks, Clock3, House, ShieldCheck, Target } from '@wso2/oxygen-ui-icons-react' import { useTranslation } from 'react-i18next' import { useLocation, useNavigate } from 'react-router-dom' +import useAuthorization from '../../../features/auth/useAuthorization' +import { PORTAL_SCOPES, type PortalScope } from '../../../utils/portalScopes' interface AppSidebarProps { collapsed: boolean @@ -30,6 +32,7 @@ interface SidebarItem { labelKey: string path: string icon: React.JSX.Element + requiredScope: PortalScope } const DASHBOARD_ITEMS: SidebarItem[] = [ @@ -38,6 +41,7 @@ const DASHBOARD_ITEMS: SidebarItem[] = [ labelKey: 'sidebar.dashboard', path: '/dashboard', icon: , + requiredScope: PORTAL_SCOPES.CONSENTS_READ_SELF, }, ] @@ -47,12 +51,14 @@ const CONSENT_ITEMS: SidebarItem[] = [ labelKey: 'sidebar.allConsents', path: '/consents', icon: , + requiredScope: PORTAL_SCOPES.CONSENTS_READ_SELF, }, { id: 'pending-consents', labelKey: 'sidebar.pendingConsents', path: '/consents?status=Pending', icon: , + requiredScope: PORTAL_SCOPES.CONSENTS_READ_SELF, }, ] @@ -62,17 +68,17 @@ const CATALOG_ITEMS: SidebarItem[] = [ labelKey: 'sidebar.purposes', path: '/purposes', icon: , + requiredScope: PORTAL_SCOPES.PURPOSES_READ, }, { id: 'elements', labelKey: 'sidebar.elements', path: '/elements', icon: , + requiredScope: PORTAL_SCOPES.ELEMENTS_READ, }, ] -const SIDEBAR_ITEMS: SidebarItem[] = [...DASHBOARD_ITEMS, ...CONSENT_ITEMS, ...CATALOG_ITEMS] - function mapPathToMenuId(pathname: string, search: string): string { if (pathname.startsWith('/dashboard')) { return 'dashboard' @@ -103,6 +109,12 @@ function AppSidebar({ collapsed }: AppSidebarProps): React.JSX.Element { const { t } = useTranslation('common') const navigate = useNavigate() const location = useLocation() + const { hasScope } = useAuthorization() + + const dashboardItems = DASHBOARD_ITEMS.filter((item) => hasScope(item.requiredScope)) + const consentItems = CONSENT_ITEMS.filter((item) => hasScope(item.requiredScope)) + const catalogItems = CATALOG_ITEMS.filter((item) => hasScope(item.requiredScope)) + const visibleItems = [...dashboardItems, ...consentItems, ...catalogItems] const activeItem = mapPathToMenuId(location.pathname, location.search) @@ -111,7 +123,7 @@ function AppSidebar({ collapsed }: AppSidebarProps): React.JSX.Element { collapsed={collapsed} activeItem={activeItem} onSelect={(id) => { - const selectedItem = SIDEBAR_ITEMS.find((item) => item.id === id) + const selectedItem = visibleItems.find((item) => item.id === id) if (selectedItem) { navigate(selectedItem.path) @@ -120,34 +132,40 @@ function AppSidebar({ collapsed }: AppSidebarProps): React.JSX.Element { aria-label={t('sidebar.ariaLabel')} > - - {DASHBOARD_ITEMS.map((item) => ( - - {item.icon} - {t(item.labelKey)} - - ))} - - - - {t('sidebar.consent')} - {CONSENT_ITEMS.map((item) => ( - - {item.icon} - {t(item.labelKey)} - - ))} - - - - {t('sidebar.catalog')} - {CATALOG_ITEMS.map((item) => ( - - {item.icon} - {t(item.labelKey)} - - ))} - + {dashboardItems.length > 0 ? ( + + {dashboardItems.map((item) => ( + + {item.icon} + {t(item.labelKey)} + + ))} + + ) : null} + + {consentItems.length > 0 ? ( + + {t('sidebar.consent')} + {consentItems.map((item) => ( + + {item.icon} + {t(item.labelKey)} + + ))} + + ) : null} + + {catalogItems.length > 0 ? ( + + {t('sidebar.catalog')} + {catalogItems.map((item) => ( + + {item.icon} + {t(item.labelKey)} + + ))} + + ) : null} ) diff --git a/portal/frontend/src/features/auth/AuthorizationProvider.tsx b/portal/frontend/src/features/auth/AuthorizationProvider.tsx new file mode 100644 index 00000000..151a7691 --- /dev/null +++ b/portal/frontend/src/features/auth/AuthorizationProvider.tsx @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useCallback, useMemo, type ReactNode } from 'react' +import type { CurrentUser } from '../../types/auth' +import type { PortalScope } from '../../utils/portalScopes' +import AuthorizationContext from './authorizationContext' + +interface AuthorizationProviderProps { + currentUser: CurrentUser + children: ReactNode +} + +export function AuthorizationProvider({ + currentUser, + children, +}: AuthorizationProviderProps): React.JSX.Element { + const grantedScopes = useMemo( + () => new Set(currentUser.scopes), + [currentUser.scopes], + ) + const hasScope = useCallback( + (scope: PortalScope): boolean => grantedScopes.has(scope), + [grantedScopes], + ) + const hasAnyScope = useCallback( + (scopes: readonly PortalScope[]): boolean => scopes.some((scope) => grantedScopes.has(scope)), + [grantedScopes], + ) + const hasAllScopes = useCallback( + (scopes: readonly PortalScope[]): boolean => scopes.every((scope) => grantedScopes.has(scope)), + [grantedScopes], + ) + const value = useMemo( + () => ({ currentUser, hasScope, hasAnyScope, hasAllScopes }), + [currentUser, hasAllScopes, hasAnyScope, hasScope], + ) + + return {children} +} diff --git a/portal/frontend/src/features/auth/NoAccessPage.tsx b/portal/frontend/src/features/auth/NoAccessPage.tsx new file mode 100644 index 00000000..0d5611ab --- /dev/null +++ b/portal/frontend/src/features/auth/NoAccessPage.tsx @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Box, Stack, Typography } from '@wso2/oxygen-ui' +import { ShieldX } from '@wso2/oxygen-ui-icons-react' +import { useTranslation } from 'react-i18next' + +function NoAccessPage(): React.JSX.Element { + const { t } = useTranslation('common') + + return ( + + + + + ) +} + +export default NoAccessPage diff --git a/portal/frontend/src/features/auth/ScopeGuard.tsx b/portal/frontend/src/features/auth/ScopeGuard.tsx new file mode 100644 index 00000000..74058804 --- /dev/null +++ b/portal/frontend/src/features/auth/ScopeGuard.tsx @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { PortalScope } from '../../utils/portalScopes' +import useAuthorization from './useAuthorization' + +interface ScopeGuardProps { + scope: PortalScope + children: React.JSX.Element +} + +function ScopeGuard({ scope, children }: ScopeGuardProps): React.JSX.Element | null { + const { hasScope } = useAuthorization() + return hasScope(scope) ? children : null +} + +export default ScopeGuard diff --git a/portal/frontend/src/features/auth/api/currentUserApi.ts b/portal/frontend/src/features/auth/api/currentUserApi.ts new file mode 100644 index 00000000..0283ef01 --- /dev/null +++ b/portal/frontend/src/features/auth/api/currentUserApi.ts @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { CurrentUser } from '../../../types/auth' +import { apiRequest } from '../../../utils/apiClient' +import { isPortalScope } from '../../../utils/portalScopes' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function parseCurrentUser(value: unknown): CurrentUser { + if ( + !isRecord(value) || + typeof value.userId !== 'string' || + !value.userId.trim() || + typeof value.organizationId !== 'string' || + !value.organizationId.trim() || + !Array.isArray(value.scopes) || + !value.scopes.every(isPortalScope) + ) { + throw new Error('invalid current-user response') + } + + return { + userId: value.userId, + organizationId: value.organizationId, + scopes: value.scopes, + } +} + +export async function fetchCurrentUser(): Promise { + return parseCurrentUser(await apiRequest('/me', { method: 'GET' })) +} diff --git a/portal/frontend/src/features/auth/authorizationContext.ts b/portal/frontend/src/features/auth/authorizationContext.ts new file mode 100644 index 00000000..916d46d1 --- /dev/null +++ b/portal/frontend/src/features/auth/authorizationContext.ts @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createContext } from 'react' +import type { CurrentUser } from '../../types/auth' +import type { PortalScope } from '../../utils/portalScopes' + +export interface AuthorizationContextValue { + currentUser: CurrentUser + hasScope: (scope: PortalScope) => boolean + hasAnyScope: (scopes: readonly PortalScope[]) => boolean + hasAllScopes: (scopes: readonly PortalScope[]) => boolean +} + +const AuthorizationContext = createContext(undefined) + +export default AuthorizationContext diff --git a/portal/frontend/src/features/auth/authorizationRoutes.ts b/portal/frontend/src/features/auth/authorizationRoutes.ts new file mode 100644 index 00000000..3e7955f5 --- /dev/null +++ b/portal/frontend/src/features/auth/authorizationRoutes.ts @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { PORTAL_SCOPES, type PortalScope } from '../../utils/portalScopes' + +const AUTHORIZED_DESTINATIONS: ReadonlyArray<{ path: string; scope: PortalScope }> = [ + { path: '/dashboard', scope: PORTAL_SCOPES.CONSENTS_READ_SELF }, + { path: '/consents', scope: PORTAL_SCOPES.CONSENTS_READ_SELF }, + { path: '/purposes', scope: PORTAL_SCOPES.PURPOSES_READ }, + { path: '/elements', scope: PORTAL_SCOPES.ELEMENTS_READ }, +] + +export default function firstAuthorizedPath(scopes: readonly PortalScope[]): string | undefined { + const granted = new Set(scopes) + return AUTHORIZED_DESTINATIONS.find(({ scope }) => granted.has(scope))?.path +} diff --git a/portal/frontend/src/features/auth/hooks/useCurrentUserQuery.ts b/portal/frontend/src/features/auth/hooks/useCurrentUserQuery.ts new file mode 100644 index 00000000..697928ff --- /dev/null +++ b/portal/frontend/src/features/auth/hooks/useCurrentUserQuery.ts @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { type UseQueryResult, useQuery } from '@tanstack/react-query' +import type { CurrentUser } from '../../../types/auth' +import { fetchCurrentUser } from '../api/currentUserApi' + +export const CURRENT_USER_QUERY_KEY = ['current-user'] as const + +export default function useCurrentUserQuery(enabled: boolean): UseQueryResult { + return useQuery({ + queryKey: CURRENT_USER_QUERY_KEY, + queryFn: fetchCurrentUser, + enabled, + retry: false, + }) +} diff --git a/portal/frontend/src/features/auth/useAuthorization.ts b/portal/frontend/src/features/auth/useAuthorization.ts new file mode 100644 index 00000000..9ab8e72d --- /dev/null +++ b/portal/frontend/src/features/auth/useAuthorization.ts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useContext } from 'react' +import AuthorizationContext, { type AuthorizationContextValue } from './authorizationContext' + +export default function useAuthorization(): AuthorizationContextValue { + const value = useContext(AuthorizationContext) + if (!value) { + throw new Error('useAuthorization must be used within AuthorizationProvider') + } + return value +} diff --git a/portal/frontend/src/features/catalog/ElementDetailsPage.tsx b/portal/frontend/src/features/catalog/ElementDetailsPage.tsx index 17bf1869..5c6f5e18 100644 --- a/portal/frontend/src/features/catalog/ElementDetailsPage.tsx +++ b/portal/frontend/src/features/catalog/ElementDetailsPage.tsx @@ -58,8 +58,10 @@ import { useTranslation } from 'react-i18next' import { useNavigate, useParams } from 'react-router-dom' import CopyableText from '../../components/CopyableText' import HeaderBreadcrumbs from '../../components/layout/main-layout/HeaderBreadcrumbs' +import useAuthorization from '../auth/useAuthorization' import type { ElementVersionItem } from '../../types/catalog' import { formatEpochTimestamp } from '../../utils/dateTime' +import { PORTAL_SCOPES } from '../../utils/portalScopes' import DeleteVersionDialog from './components/DeleteVersionDialog' import ElementFormDialog from './components/ElementFormDialog' import ElementTypeChip from './components/ElementTypeChip' @@ -128,6 +130,8 @@ function ElementDetailsPage(): React.JSX.Element { const [selectedVersion, setSelectedVersion] = useState() const [versionDialogOpen, setVersionDialogOpen] = useState(false) const [deleteVersion, setDeleteVersion] = useState() + const { hasScope } = useAuthorization() + const canWriteElements = hasScope(PORTAL_SCOPES.ELEMENTS_WRITE) const detail = detailQuery.data const latestVersion = useMemo( @@ -223,13 +227,15 @@ function ElementDetailsPage(): React.JSX.Element { ))} - + {canWriteElements ? ( + + ) : null} @@ -489,18 +495,20 @@ function ElementDetailsPage(): React.JSX.Element { {version.description ?? '-'} {formatEpochTimestamp(version.createdTime)} - - { - event.stopPropagation() - setDeleteVersion(version.version) - }} - > - - - + {canWriteElements ? ( + + { + event.stopPropagation() + setDeleteVersion(version.version) + }} + > + + + + ) : null} ))} diff --git a/portal/frontend/src/features/catalog/ElementListPage.tsx b/portal/frontend/src/features/catalog/ElementListPage.tsx index fc1c5d4f..55cf7adc 100644 --- a/portal/frontend/src/features/catalog/ElementListPage.tsx +++ b/portal/frontend/src/features/catalog/ElementListPage.tsx @@ -43,8 +43,10 @@ import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate, useSearchParams } from 'react-router-dom' import HeaderBreadcrumbs from '../../components/layout/main-layout/HeaderBreadcrumbs' +import useAuthorization from '../auth/useAuthorization' import type { ElementFilters, ElementType } from '../../types/catalog' import { formatEpochTimestamp } from '../../utils/dateTime' +import { PORTAL_SCOPES } from '../../utils/portalScopes' import ElementFormDialog from './components/ElementFormDialog' import ElementTypeChip from './components/ElementTypeChip' import { useCreateElementMutation, useElementsQuery } from './hooks/useCatalogQueries' @@ -289,6 +291,8 @@ function ElementListPage(): React.JSX.Element { const rowsPerPage = useMemo(() => getRowsPerPage(searchParams), [searchParams]) const query = useElementsQuery(filters, page, rowsPerPage) const createMutation = useCreateElementMutation() + const { hasScope } = useAuthorization() + const canWriteElements = hasScope(PORTAL_SCOPES.ELEMENTS_WRITE) const updateParams = ( nextFilters: ElementFilters, @@ -343,13 +347,15 @@ function ElementListPage(): React.JSX.Element { {t('catalog.elements.subtitle')} - + {canWriteElements ? ( + + ) : null} () const [versionDialogOpen, setVersionDialogOpen] = useState(false) const [deleteVersion, setDeleteVersion] = useState() + const { hasScope } = useAuthorization() + const canWritePurposes = hasScope(PORTAL_SCOPES.PURPOSES_WRITE) const detail = detailQuery.data const latestVersion = useMemo( @@ -225,13 +229,15 @@ function PurposeDetailsPage(): React.JSX.Element { ))} - + {canWritePurposes ? ( + + ) : null} @@ -549,18 +555,20 @@ function PurposeDetailsPage(): React.JSX.Element { {version.elements.length} {formatEpochTimestamp(version.createdTime)} - - { - event.stopPropagation() - setDeleteVersion(version.version) - }} - > - - - + {canWritePurposes ? ( + + { + event.stopPropagation() + setDeleteVersion(version.version) + }} + > + + + + ) : null} ))} diff --git a/portal/frontend/src/features/catalog/PurposeListPage.tsx b/portal/frontend/src/features/catalog/PurposeListPage.tsx index 3958ea84..aa1770e2 100644 --- a/portal/frontend/src/features/catalog/PurposeListPage.tsx +++ b/portal/frontend/src/features/catalog/PurposeListPage.tsx @@ -45,8 +45,10 @@ import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate, useSearchParams } from 'react-router-dom' import HeaderBreadcrumbs from '../../components/layout/main-layout/HeaderBreadcrumbs' +import useAuthorization from '../auth/useAuthorization' import type { ElementSummary, PurposeFilters } from '../../types/catalog' import { formatEpochTimestamp } from '../../utils/dateTime' +import { PORTAL_SCOPES } from '../../utils/portalScopes' import ElementVersionSelect from './components/ElementVersionSelect' import PurposeFormDialog from './components/PurposeFormDialog' import { @@ -379,6 +381,8 @@ function PurposeListPage(): React.JSX.Element { const rowsPerPage = ROW_OPTIONS.includes(parsedRows) ? parsedRows : 10 const query = usePurposesQuery(filters, page, rowsPerPage) const createMutation = useCreatePurposeMutation() + const { hasScope } = useAuthorization() + const canWritePurposes = hasScope(PORTAL_SCOPES.PURPOSES_WRITE) const updateParams = ( nextFilters: PurposeListFilters, @@ -438,13 +442,15 @@ function PurposeListPage(): React.JSX.Element { {t('catalog.purposes.subtitle')} - + {canWritePurposes ? ( + + ) : null} (false) const [resourcesModalOpen, setResourcesModalOpen] = useState(false) const [selectedResourcesJson, setSelectedResourcesJson] = useState('') + const { hasScope } = useAuthorization() + const canWriteSelf = hasScope(PORTAL_SCOPES.CONSENTS_WRITE_SELF) if (!id) { return ( @@ -179,9 +183,9 @@ function ConsentDetailsPage(): React.JSX.Element { } const detail = consentDetailQuery.data - const canApprove = detail ? isConsentApprovableStatus(detail.status) : false - const canReject = detail ? isConsentRejectableStatus(detail.status) : false - const canRevoke = detail ? isConsentRevokableStatus(detail.status) : false + const canApprove = detail ? canWriteSelf && isConsentApprovableStatus(detail.status) : false + const canReject = detail ? canWriteSelf && isConsentRejectableStatus(detail.status) : false + const canRevoke = detail ? canWriteSelf && isConsentRevokableStatus(detail.status) : false if (consentDetailQuery.isLoading) { return diff --git a/portal/frontend/src/features/consent-registry/hooks/useConsentQueries.ts b/portal/frontend/src/features/consent-registry/hooks/useConsentQueries.ts index 3aae267b..53182ed8 100644 --- a/portal/frontend/src/features/consent-registry/hooks/useConsentQueries.ts +++ b/portal/frontend/src/features/consent-registry/hooks/useConsentQueries.ts @@ -48,6 +48,8 @@ import type { ConsentRegistrySortField, } from '../../../types/consent' import { isConsentAPIStatus } from '../../../types/consent' +import useAuthorization from '../../auth/useAuthorization' +import { PORTAL_SCOPES } from '../../../utils/portalScopes' import { toEndOfDayEpochMilliseconds, toEpochMilliseconds, @@ -91,7 +93,7 @@ function toListParams( } } -function toConsentRow(consent: ConsentDetailAPI): ConsentRecord { +function toConsentRow(consent: ConsentDetailAPI, canWriteSelf: boolean): ConsentRecord { const normalizedStatus = normalizeConsentStatus(consent.status) if (!isConsentAPIStatus(normalizedStatus)) { @@ -106,8 +108,8 @@ function toConsentRow(consent: ConsentDetailAPI): ConsentRecord { purposes: consent.purposes.map((purpose) => purpose.displayName ?? purpose.name), updatedAt: new Date(toEpochMilliseconds(consent.updatedTime) ?? 0).toISOString(), expirationTime: consent.expirationTime ?? 0, - canRevoke: isConsentRevokableStatus(normalizedStatus), - canApprove: isConsentApprovableStatus(normalizedStatus), + canRevoke: canWriteSelf && isConsentRevokableStatus(normalizedStatus), + canApprove: canWriteSelf && isConsentApprovableStatus(normalizedStatus), } } @@ -117,15 +119,16 @@ function consentListQueryOptions( rowsPerPage: number, sortField: ConsentRegistrySortField, sortDirection: ConsentRegistrySortDirection, + canWriteSelf: boolean, ) { const params = toListParams(filters, page, rowsPerPage, sortField, sortDirection) return queryOptions({ - queryKey: ['consents', params], + queryKey: ['consents', params, { canWriteSelf }], queryFn: async (): Promise => { const response = await fetchMyConsents(params) return { - rows: response.data.map(toConsentRow), + rows: response.data.map((consent) => toConsentRow(consent, canWriteSelf)), total: response.metadata.total, } }, @@ -141,8 +144,10 @@ export function useConsentListQuery( sortDirection: ConsentRegistrySortDirection, ): UseQueryResult { const queryClient = useQueryClient() + const { hasScope } = useAuthorization() + const canWriteSelf = hasScope(PORTAL_SCOPES.CONSENTS_WRITE_SELF) const query = useQuery( - consentListQueryOptions(filters, page, rowsPerPage, sortField, sortDirection), + consentListQueryOptions(filters, page, rowsPerPage, sortField, sortDirection, canWriteSelf), ) useEffect(() => { @@ -152,12 +157,20 @@ export function useConsentListQuery( if (!query.isPlaceholderData && hasNextPage) { queryClient .prefetchQuery( - consentListQueryOptions(filters, nextPage, rowsPerPage, sortField, sortDirection), + consentListQueryOptions( + filters, + nextPage, + rowsPerPage, + sortField, + sortDirection, + canWriteSelf, + ), ) .catch(() => undefined) } }, [ filters, + canWriteSelf, page, query.data?.total, query.isPlaceholderData, diff --git a/portal/frontend/src/features/dashboard/DashboardPage.tsx b/portal/frontend/src/features/dashboard/DashboardPage.tsx index b9377878..0a783cbe 100644 --- a/portal/frontend/src/features/dashboard/DashboardPage.tsx +++ b/portal/frontend/src/features/dashboard/DashboardPage.tsx @@ -1,6 +1,19 @@ /* * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * Licensed under the Apache License, Version 2.0. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ import { diff --git a/portal/frontend/src/features/dashboard/hooks/useDashboardConsentsQuery.ts b/portal/frontend/src/features/dashboard/hooks/useDashboardConsentsQuery.ts index 91574537..1f1ac612 100644 --- a/portal/frontend/src/features/dashboard/hooks/useDashboardConsentsQuery.ts +++ b/portal/frontend/src/features/dashboard/hooks/useDashboardConsentsQuery.ts @@ -1,6 +1,19 @@ /* * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * Licensed under the Apache License, Version 2.0. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ import { type UseQueryResult, useQuery } from '@tanstack/react-query' diff --git a/portal/frontend/src/i18n/resources/en/common.ts b/portal/frontend/src/i18n/resources/en/common.ts index 61d30269..ccd93cbe 100644 --- a/portal/frontend/src/i18n/resources/en/common.ts +++ b/portal/frontend/src/i18n/resources/en/common.ts @@ -20,6 +20,13 @@ const commonEn = { app: { title: 'Consent Portal', }, + authorization: { + loading: 'Loading your session', + loadFailed: 'Unable to load your session.', + tryAgain: 'Try again', + noAccessTitle: 'No portal access', + noAccessDescription: 'Your account does not have permission to access any portal pages.', + }, copyableText: { copy: 'Copy', copied: 'Copied', diff --git a/portal/frontend/src/types/auth.ts b/portal/frontend/src/types/auth.ts new file mode 100644 index 00000000..f1e650bc --- /dev/null +++ b/portal/frontend/src/types/auth.ts @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { PortalScope } from '../utils/portalScopes' + +export interface CurrentUser { + userId: string + organizationId: string + scopes: PortalScope[] +} diff --git a/portal/frontend/src/utils/portalScopes.ts b/portal/frontend/src/utils/portalScopes.ts new file mode 100644 index 00000000..3893d175 --- /dev/null +++ b/portal/frontend/src/utils/portalScopes.ts @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const PORTAL_SCOPES = { + CONSENTS_READ_SELF: 'portal:consents:read:self', + CONSENTS_WRITE_SELF: 'portal:consents:write:self', + CONSENTS_READ_ANY: 'portal:consents:read:any', + CONSENTS_WRITE_ANY: 'portal:consents:write:any', + ELEMENTS_READ: 'portal:elements:read', + ELEMENTS_WRITE: 'portal:elements:write', + PURPOSES_READ: 'portal:purposes:read', + PURPOSES_WRITE: 'portal:purposes:write', +} as const + +export type PortalScope = (typeof PORTAL_SCOPES)[keyof typeof PORTAL_SCOPES] + +const PORTAL_SCOPE_VALUES = new Set(Object.values(PORTAL_SCOPES)) + +export function isPortalScope(value: unknown): value is PortalScope { + return typeof value === 'string' && PORTAL_SCOPE_VALUES.has(value) +} From 3abc60f26b6d6447697fda64e10cdaf20de43393 Mon Sep 17 00:00:00 2001 From: rashmithachamikara Date: Sun, 2 Aug 2026 18:18:14 +0530 Subject: [PATCH 3/7] Improve docs --- portal/backend/README.md | 7 +------ portal/backend/docs/README.md | 11 +++++++---- portal/frontend/README.md | 6 ++++++ 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/portal/backend/README.md b/portal/backend/README.md index 62b42fb6..f5ddf5b8 100644 --- a/portal/backend/README.md +++ b/portal/backend/README.md @@ -133,11 +133,6 @@ Common error codes: ## AI Instructions -This repository uses VS Code Copilot instruction files to keep AI-generated changes aligned with project and organization standards. +This repository uses AGENTS.md files to keep AI-generated changes aligned with project and organization standards. - Backend standards: `portal/backend/AGENTS.md` -- Copilot workspace entrypoint (repo root): `.github/copilot-instructions.md` -- Scoped instructions folder (repo root): `.github/instructions/` -- Backend scope mapping: `portal/backend/**` -> `.github/instructions/portal-backend.instructions.md` - -Copilot instructions are discovered automatically and scoped using `applyTo` patterns. diff --git a/portal/backend/docs/README.md b/portal/backend/docs/README.md index f1db9eee..054c6d52 100644 --- a/portal/backend/docs/README.md +++ b/portal/backend/docs/README.md @@ -32,7 +32,7 @@ React frontend ─────► Portal Backend ─────► OpenFGC | Component | Responsibility | | --- | --- | -| React frontend | Starts login and logout, sends the readable access-token half, refreshes expired sessions, and renders identity claims for display. It does not make authorization decisions. | +| React frontend | Starts login and logout, sends the readable access-token half, refreshes expired sessions, renders ID-token claims for display, and uses the effective scopes returned by `GET /me` for route and element visibility. These UI decisions improve the user experience; Portal Backend scope enforcement remains the security boundary. | | Portal Backend | Acts as the OIDC confidential client and protected API boundary. It validates tokens, creates the trusted principal, enforces route policy, and maps allowed requests to OpenFGC. | | Identity provider | Authenticates users, exposes OIDC discovery and JWKS metadata, and issues signed ID, access, and refresh tokens. | | OpenFGC | Performs consent operations behind the Portal Backend. It receives identity and organization context derived by the Portal Backend. | @@ -87,6 +87,9 @@ The frontend deduplicates concurrent refresh attempts within the current browser | `GET /health`, `/health/liveness`, `/health/readiness` | Public | | `GET /auth/login`, `GET /auth/callback`, `POST /auth/refresh` | Public protocol endpoints; login and refresh still require their corresponding split transaction or token material. | | `POST /auth/logout` | Authenticated | +| `GET /me` | Authenticated without a business-scope requirement; returns the validated user ID, organization ID, and effective portal scopes. | + +`GET /me` returns only scopes in the canonical portal registry, preserving registry order. OIDC scopes such as `openid`, `profile`, and `email` are excluded. An authenticated principal without portal permissions receives `200 OK` with an empty `scopes` array. ### Current-user consent routes @@ -131,9 +134,9 @@ Configuration is grouped into: - `BFF_LOG__*`: log level. - `BFF_CORS__*`: exact browser origins, methods, headers, and credential support. - `BFF_AUTH__*`: OIDC client, token validation, claims, timeouts, cookies, and token size limits. -- `BFF_PROXY__*`: OpenFGC URL, request and response limits, timeout, passthrough methods, and local placeholder identity. +- `BFF_PROXY__*`: OpenFGC URL, request and response limits, timeout, passthrough methods, and local placeholder identity and scopes. -Production startup requires authentication, HTTPS authentication URLs, and secure cookies. Placeholder identity cannot run in production and cannot be enabled together with OIDC authentication. In permitted local environments, placeholder mode supplies a configured user and organization with all portal scopes for development without an identity provider. +Production startup requires authentication, HTTPS authentication URLs, and secure cookies. Placeholder identity cannot run in production and cannot be enabled together with OIDC authentication. In permitted local environments, placeholder mode supplies the configured user and organization without an identity provider. `BFF_PROXY__PLACEHOLDER_SCOPES` is a space-separated list of canonical portal scopes granted to that principal. Unknown or duplicate scopes are rejected, and an empty list creates an authenticated principal without portal permissions. Placeholder identity and scope values must be empty when placeholder mode is disabled. ### Local topology @@ -150,6 +153,6 @@ The backend Docker Compose file builds and publishes the Portal Backend on port ## Testing -Backend unit and integration tests cover configuration guardrails, OIDC transactions, token splitting and validation, cookie handling, authorization scopes, CORS, current-user ownership, proxy mappings, header sanitization, limits, and upstream failures. Frontend tests cover the auth client, refresh retry behavior, profile rendering, API integration, and production CSP generation. +Backend unit and integration tests cover configuration guardrails, OIDC transactions, token splitting and validation, cookie handling, the `GET /me` session response, placeholder-scope filtering, authorization scopes, CORS, current-user ownership, proxy mappings, header sanitization, limits, and upstream failures. Frontend tests cover the `/me` session gate, scope-based routes and elements, sidebar visibility, authorization redirects, auth-client refresh behavior, profile rendering, API integration, and production CSP generation. A frontend test parses the backend OpenAPI document and detects missing, extra, empty, or duplicate portal scopes relative to the centralized `PORTAL_SCOPES` registry. Use the commands in the [backend README](../README.md) and [frontend README](../../frontend/README.md) to run their respective checks. Changes to routes or scopes must also update and validate the [OpenAPI specification](../openapi/portal-backend.yaml). diff --git a/portal/frontend/README.md b/portal/frontend/README.md index 31545cee..e4a08c2a 100644 --- a/portal/frontend/README.md +++ b/portal/frontend/README.md @@ -151,3 +151,9 @@ function Example(): React.JSX.Element { ## CI GitHub Actions CI runs pnpm-based install, lint, and build checks on every push and pull request on portal/frontend directory. + +## AI Instructions + +This repository uses AGENTS.md files to keep AI-generated changes aligned with project and organization standards. + +- Frontend standards: `portal/frontend/AGENTS.md` From 248d5f7517f93fcf154ac887fb2a2d1798dfd30c Mon Sep 17 00:00:00 2001 From: rashmithachamikara Date: Sun, 2 Aug 2026 18:41:09 +0530 Subject: [PATCH 4/7] Replace env VITE_ORG_ID with auth derived org --- portal/frontend/.env.example | 1 - portal/frontend/README.md | 4 +-- .../src/__tests__/PurposeFormDialog.test.tsx | 1 + .../features/catalog/PurposeDetailsPage.tsx | 7 ++--- .../src/features/catalog/PurposeListPage.tsx | 29 ++++++++++++------- .../catalog/components/PurposeFormDialog.tsx | 6 ++-- portal/frontend/vitest.setup.ts | 1 - 7 files changed, 28 insertions(+), 21 deletions(-) diff --git a/portal/frontend/.env.example b/portal/frontend/.env.example index ff0529f8..5fa0389a 100644 --- a/portal/frontend/.env.example +++ b/portal/frontend/.env.example @@ -1,5 +1,4 @@ VITE_API_BASE_URL=http://localhost:8080 -VITE_ORG_ID=ORG-001 VITE_AUTH_ENABLED=false VITE_AUTH_ACCESS_TOKEN_PART1_COOKIE=portal-at-p1 VITE_AUTH_REFRESH_TOKEN_PART1_COOKIE=portal-rt-p1 diff --git a/portal/frontend/README.md b/portal/frontend/README.md index e4a08c2a..a9b53e2c 100644 --- a/portal/frontend/README.md +++ b/portal/frontend/README.md @@ -65,7 +65,6 @@ Note: If your machine cannot install Corepack shims, prefix pnpm commands with ` Alternatively, follow the other [recommended pnpm installation options](https://pnpm.io/installation). - ## Environment Create a local `.env` file from `.env.example` before running or building the portal. @@ -73,7 +72,6 @@ Create a local `.env` file from `.env.example` before running or building the po | Variable | Description | Example | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------- | | `VITE_API_BASE_URL` | Required base URL for the OpenFGC Portal backend API. Vite embeds this value at build time. | `http://localhost:8080` | -| `VITE_ORG_ID` | Organization ID used to identify and filter organization-wide consent purposes. | `ORG-001` | | `VITE_AUTH_ENABLED` | Enables frontend authentication gating; set to `true` for protected deployments. | `true` | | `VITE_AUTH_ACCESS_TOKEN_PART1_COOKIE` | Cookie name for the readable access-token part. | `portal-at-p1` | | `VITE_AUTH_REFRESH_TOKEN_PART1_COOKIE` | Cookie name for the readable refresh-token part. | `portal-rt-p1` | @@ -81,6 +79,8 @@ Create a local `.env` file from `.env.example` before running or building the po | `VITE_AUTH_ID_TOKEN_PART2_COOKIE` | Cookie name for ID-token part 2. | `portal-id-p2` | | `VITE_AUTH_LOGOUT_ALLOWED_ORIGINS` | Exact comma-separated origins accepted for logout navigation. Include the IdP origin when using its end-session endpoint. | `https://idp.example.com` | +The authenticated user and organization IDs are resolved from `GET /me`; they are not frontend build-time configuration. + ## Production security headers `pnpm build` emits `dist/_headers`; configure the production static host to apply diff --git a/portal/frontend/src/__tests__/PurposeFormDialog.test.tsx b/portal/frontend/src/__tests__/PurposeFormDialog.test.tsx index 615fe596..ac3b3c74 100644 --- a/portal/frontend/src/__tests__/PurposeFormDialog.test.tsx +++ b/portal/frontend/src/__tests__/PurposeFormDialog.test.tsx @@ -62,6 +62,7 @@ function renderDialog(overrides: Partial = { open: true, initialValue: undefined, + organizationId: 'test-org', loading: false, error: undefined, onClose: vi.fn(), diff --git a/portal/frontend/src/features/catalog/PurposeDetailsPage.tsx b/portal/frontend/src/features/catalog/PurposeDetailsPage.tsx index 69e8a912..a627d114 100644 --- a/portal/frontend/src/features/catalog/PurposeDetailsPage.tsx +++ b/portal/frontend/src/features/catalog/PurposeDetailsPage.tsx @@ -71,8 +71,6 @@ import { usePurposeVersionsQuery, } from './hooks/useCatalogQueries' -const ORG_ID = String(import.meta.env.VITE_ORG_ID ?? '').trim() - interface DetailField { icon: React.ReactNode label: string @@ -131,7 +129,7 @@ function PurposeDetailsPage(): React.JSX.Element { const [selectedVersion, setSelectedVersion] = useState() const [versionDialogOpen, setVersionDialogOpen] = useState(false) const [deleteVersion, setDeleteVersion] = useState() - const { hasScope } = useAuthorization() + const { currentUser, hasScope } = useAuthorization() const canWritePurposes = hasScope(PORTAL_SCOPES.PURPOSES_WRITE) const detail = detailQuery.data @@ -186,7 +184,7 @@ function PurposeDetailsPage(): React.JSX.Element { ) } - const organizationWide = Boolean(ORG_ID) && detail.groupId === ORG_ID + const organizationWide = detail.groupId === currentUser.organizationId const propertyEntries = Object.entries(displayedVersion.properties ?? {}) return ( @@ -583,6 +581,7 @@ function PurposeDetailsPage(): React.JSX.Element { key={`purpose-version-${String(versionDialogOpen)}`} open={versionDialogOpen} initialValue={detail} + organizationId={currentUser.organizationId} loading={createVersionMutation.isPending} error={createVersionMutation.error?.message} onClose={() => { diff --git a/portal/frontend/src/features/catalog/PurposeListPage.tsx b/portal/frontend/src/features/catalog/PurposeListPage.tsx index aa1770e2..71e05f5a 100644 --- a/portal/frontend/src/features/catalog/PurposeListPage.tsx +++ b/portal/frontend/src/features/catalog/PurposeListPage.tsx @@ -58,7 +58,6 @@ import { } from './hooks/useCatalogQueries' const ROW_OPTIONS = [10, 25, 50] -const ORG_ID = String(import.meta.env.VITE_ORG_ID ?? '').trim() type PurposeScope = 'organization' | 'all' @@ -70,18 +69,18 @@ function elementLabel(element: ElementSummary): string { return `${element.displayName ?? element.name} (${element.namespace})` } -function emptyFilters(): PurposeListFilters { +function emptyFilters(organizationId: string): PurposeListFilters { return { purposeName: '', elementName: '', elementNamespace: '', elementVersion: '', - groupIds: ORG_ID, + groupIds: organizationId, scope: 'organization', } } -function getFilters(searchParams: URLSearchParams): PurposeListFilters { +function getFilters(searchParams: URLSearchParams, organizationId: string): PurposeListFilters { const scope: PurposeScope = searchParams.get('scope') === 'all' ? 'all' : 'organization' return { @@ -89,19 +88,21 @@ function getFilters(searchParams: URLSearchParams): PurposeListFilters { elementName: searchParams.get('elementName') ?? '', elementNamespace: searchParams.get('elementNamespace') ?? '', elementVersion: searchParams.get('elementVersion') ?? '', - groupIds: scope === 'organization' ? ORG_ID : (searchParams.get('groupIds') ?? ''), + groupIds: scope === 'organization' ? organizationId : (searchParams.get('groupIds') ?? ''), scope, } } interface PurposeFiltersPanelProps { initialFilters: PurposeListFilters + organizationId: string onApply: (filters: PurposeListFilters) => void onClear: () => void } function PurposeFiltersPanel({ initialFilters, + organizationId, onApply, onClear, }: PurposeFiltersPanelProps): React.JSX.Element { @@ -236,7 +237,7 @@ function PurposeFiltersPanel({ setDraft({ ...draft, scope: value, - groupIds: value === 'organization' ? ORG_ID : '', + groupIds: value === 'organization' ? organizationId : '', }) }} aria-label={t('catalog.purposes.scopeFilter')} @@ -343,7 +344,7 @@ function PurposeFiltersPanel({ + + ) : null} + + updateParams(filters, nextPage)} + onRowsPerPageChange={(nextRowsPerPage) => + updateParams(filters, DEFAULT_PAGE, nextRowsPerPage) + } + onSortChange={(sortField, sortDirection) => + updateParams(filters, DEFAULT_PAGE, rowsPerPage, sortField, sortDirection) + } + onRetry={() => consentListQuery.refetch()} + onRevoke={setSelectedRevocationConsentID} + isMutating={revokeMutation.isPending} + /> + + {selectedRevocationConsentID ? ( + setSelectedRevocationConsentID(undefined)} + onConfirm={() => + revokeMutation.mutate( + { consentID: selectedRevocationConsentID, actionBy: currentUser.userId }, + { onSuccess: () => setSelectedRevocationConsentID(undefined) }, + ) + } + /> + ) : null} + + + ) +} diff --git a/portal/frontend/src/features/admin-consents/api/adminConsentsApi.ts b/portal/frontend/src/features/admin-consents/api/adminConsentsApi.ts new file mode 100644 index 00000000..d06abc95 --- /dev/null +++ b/portal/frontend/src/features/admin-consents/api/adminConsentsApi.ts @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { + ConsentDetailAPI, + ConsentListQueryParams, + ConsentSearchResponse, +} from '../../../types/consent' +import { apiRequest } from '../../../utils/apiClient' + +export async function fetchAdminConsents( + params: ConsentListQueryParams, +): Promise { + return apiRequest('/api/consents', { + method: 'GET', + query: { + consentStatuses: params.consentStatuses, + userIds: params.userIds, + groupIds: params.groupIds, + purposeName: params.purposeName, + purposeVersion: params.purposeVersion, + elementName: params.elementName, + elementNamespace: params.elementNamespace, + elementVersion: params.elementVersion, + sort: params.sort, + fromTime: params.fromTime, + toTime: params.toTime, + limit: params.limit, + offset: params.offset, + details: true, + }, + }) +} + +export async function fetchAdminConsentByID(consentID: string): Promise { + return apiRequest(`/api/consents/${encodeURIComponent(consentID)}`, { + method: 'GET', + query: { details: true, includeStatusHistory: true }, + }) +} + +export async function revokeAdminConsent(consentID: string, actionBy: string): Promise { + return apiRequest(`/api/consents/${encodeURIComponent(consentID)}/revoke`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ actionBy }), + }) +} diff --git a/portal/frontend/src/features/admin-consents/components/AdminConsentFilters.tsx b/portal/frontend/src/features/admin-consents/components/AdminConsentFilters.tsx new file mode 100644 index 00000000..0b17671d --- /dev/null +++ b/portal/frontend/src/features/admin-consents/components/AdminConsentFilters.tsx @@ -0,0 +1,438 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + AdapterDateFns, + Autocomplete, + Box, + Button, + DatePickers, + FormControl, + IconButton, + InputLabel, + MenuItem, + Popover, + SearchBar, + Select, + Stack, + TextField, + Tooltip, + Typography, +} from '@wso2/oxygen-ui' +import { ListFilter } from '@wso2/oxygen-ui-icons-react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { AdminConsentRegistryFilters } from '../../../types/consent' +import type { ElementSummary } from '../../../types/catalog' +import ElementVersionSelect from '../../catalog/components/ElementVersionSelect' +import { useElementOptionsQuery } from '../../catalog/hooks/useCatalogQueries' +import { + EMPTY_ADMIN_CONSENT_FILTERS, + normalizeAdminConsentFilters, +} from '../utils/adminConsentFilters' + +interface AdminConsentFiltersProps { + filters: AdminConsentRegistryFilters + canReadElements: boolean + onFilterChange: (filters: AdminConsentRegistryFilters) => void + onClear: () => void +} + +const MAIN_FILTER_HEIGHT = 40 + +function parseDate(value: string): Date | null { + if (!value) return null + const [year, month, day] = value.split('-').map(Number) + const date = new Date(year, month - 1, day) + return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day + ? date + : null +} + +function formatDate(value: Date | null): string { + if (!value) return '' + return [ + value.getFullYear(), + String(value.getMonth() + 1).padStart(2, '0'), + String(value.getDate()).padStart(2, '0'), + ].join('-') +} + +function elementLabel(element: ElementSummary): string { + return `${element.displayName ?? element.name} (${element.namespace})` +} + +export default function AdminConsentFilters({ + filters, + canReadElements, + onFilterChange, + onClear, +}: AdminConsentFiltersProps): React.JSX.Element { + const { t } = useTranslation('common') + const [draft, setDraft] = useState(filters) + const [filtersAnchor, setFiltersAnchor] = useState(null) + const filtersOpen = Boolean(filtersAnchor) + const elementOptionsQuery = useElementOptionsQuery(filtersOpen && canReadElements) + const elementOptions = elementOptionsQuery.data?.data ?? [] + const selectedElement = + elementOptions.find( + (element) => + element.name === draft.elementName && element.namespace === draft.elementNamespace, + ) ?? null + const advancedFilterCount = [ + filters.purposeName, + filters.userIds, + filters.groupIds, + filters.purposeVersion, + filters.elementName, + filters.elementNamespace, + filters.elementVersion, + filters.startDate, + filters.endDate, + ].filter(Boolean).length + + const applyFilters = (next: AdminConsentRegistryFilters): void => { + const normalized = normalizeAdminConsentFilters(next) + setDraft(normalized) + onFilterChange(normalized) + } + + const cancelAdvancedChanges = (): void => { + setDraft(filters) + setFiltersAnchor(null) + } + + return ( + + + + setDraft({ ...draft, consentId: event.target.value })} + onKeyDown={(event) => { + if (event.key === 'Enter') applyFilters(draft) + }} + sx={{ '& .MuiInputBase-root': { height: MAIN_FILTER_HEIGHT, pr: 6 } }} + /> + + + + { + setDraft(filters) + setFiltersAnchor(event.currentTarget) + }} + > + + + + + {advancedFilterCount > 0 ? ( + + {advancedFilterCount} + + ) : null} + + + + + + + {t('consentRegistry.filters.status')} + + + + + + + + + + + {t('consentRegistry.filters.advanced')} + + + setDraft({ ...draft, userIds: event.target.value })} + /> + setDraft({ ...draft, groupIds: event.target.value })} + /> + + + { + const purposeName = event.target.value + setDraft({ + ...draft, + purposeName, + purposeVersion: purposeName.trim() ? draft.purposeVersion : '', + }) + }} + /> + + + setDraft({ ...draft, purposeVersion: event.target.value })} + /> + + + + + {canReadElements ? ( + option.elementId === value.elementId} + onChange={(_, selected) => + setDraft({ + ...draft, + elementName: selected?.name ?? '', + elementNamespace: selected?.namespace ?? '', + elementVersion: '', + }) + } + renderInput={(params) => ( + // Oxygen UI Autocomplete requires forwarding its generated input props. + // eslint-disable-next-line react/jsx-props-no-spreading + + )} + /> + ) : ( + { + const elementName = event.target.value + setDraft({ + ...draft, + elementName, + elementVersion: + elementName.trim() || draft.elementNamespace.trim() + ? draft.elementVersion + : '', + }) + }} + /> + )} + { + const elementNamespace = event.target.value + setDraft({ + ...draft, + elementNamespace, + elementVersion: + draft.elementName.trim() || elementNamespace.trim() ? draft.elementVersion : '', + }) + }} + /> + {canReadElements && selectedElement ? ( + setDraft({ ...draft, elementVersion })} + /> + ) : ( + + + setDraft({ ...draft, elementVersion: event.target.value })} + /> + + + )} + + + + setDraft({ ...draft, startDate: formatDate(value) })} + slotProps={{ textField: { size: 'small', fullWidth: true } }} + /> + setDraft({ ...draft, endDate: formatDate(value) })} + slotProps={{ textField: { size: 'small', fullWidth: true } }} + /> + + + + + + + + + + + + + ) +} diff --git a/portal/frontend/src/features/admin-consents/hooks/useAdminConsentQueries.ts b/portal/frontend/src/features/admin-consents/hooks/useAdminConsentQueries.ts new file mode 100644 index 00000000..68630841 --- /dev/null +++ b/portal/frontend/src/features/admin-consents/hooks/useAdminConsentQueries.ts @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + keepPreviousData, + queryOptions, + type UseMutationResult, + type UseQueryResult, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import { useEffect } from 'react' +import type { + AdminConsentRegistryFilters, + ConsentDetailAPI, + ConsentListQueryParams, + ConsentRecord, + ConsentRegistrySortDirection, + ConsentRegistrySortField, +} from '../../../types/consent' +import { isConsentAPIStatus } from '../../../types/consent' +import { PORTAL_SCOPES } from '../../../utils/portalScopes' +import { + toEndOfDayEpochMilliseconds, + toEpochMilliseconds, + toStartOfDayEpochMilliseconds, +} from '../../../utils/dateTime' +import useAuthorization from '../../auth/useAuthorization' +import { + isConsentRevokableStatus, + normalizeConsentStatus, +} from '../../consent-registry/utils/statusChip' +import { + fetchAdminConsentByID, + fetchAdminConsents, + revokeAdminConsent, +} from '../api/adminConsentsApi' + +interface AdminConsentListResult { + rows: ConsentRecord[] + total: number +} + +function toListParams( + filters: AdminConsentRegistryFilters, + page: number, + rowsPerPage: number, + sortField: ConsentRegistrySortField, + sortDirection: ConsentRegistrySortDirection, +): ConsentListQueryParams { + const statusFilterMap: Record, string> = { + Active: 'ACTIVE', + Pending: 'CREATED', + Rejected: 'REJECTED', + Revoked: 'REVOKED', + Expired: 'EXPIRED', + } + + return { + sort: `${sortField}:${sortDirection}`, + consentStatuses: filters.status === 'All' ? undefined : statusFilterMap[filters.status], + userIds: filters.userIds || undefined, + groupIds: filters.groupIds || undefined, + purposeName: filters.purposeName.trim() || undefined, + purposeVersion: filters.purposeName.trim() + ? filters.purposeVersion.trim() || undefined + : undefined, + elementName: filters.elementName.trim() || undefined, + elementNamespace: filters.elementNamespace.trim() || undefined, + elementVersion: + filters.elementName.trim() || filters.elementNamespace.trim() + ? filters.elementVersion.trim() || undefined + : undefined, + fromTime: toStartOfDayEpochMilliseconds(filters.startDate), + toTime: toEndOfDayEpochMilliseconds(filters.endDate), + limit: rowsPerPage, + offset: page * rowsPerPage, + } +} + +function toAdminConsentRow(consent: ConsentDetailAPI, canWriteAny: boolean): ConsentRecord { + const normalizedStatus = normalizeConsentStatus(consent.status) + if (!isConsentAPIStatus(normalizedStatus)) { + throw new Error(`Unsupported consent status received from API: ${consent.status}`) + } + + return { + id: consent.id, + groupId: consent.groupId, + type: consent.type, + status: normalizedStatus, + purposes: consent.purposes.map((purpose) => purpose.displayName ?? purpose.name), + updatedAt: new Date(toEpochMilliseconds(consent.updatedTime) ?? 0).toISOString(), + expirationTime: consent.expirationTime ?? 0, + canApprove: false, + canRevoke: canWriteAny && isConsentRevokableStatus(normalizedStatus), + } +} + +function adminConsentListQueryOptions( + filters: AdminConsentRegistryFilters, + page: number, + rowsPerPage: number, + sortField: ConsentRegistrySortField, + sortDirection: ConsentRegistrySortDirection, + canWriteAny: boolean, +) { + const consentID = filters.consentId.trim() + const params = toListParams(filters, page, rowsPerPage, sortField, sortDirection) + return queryOptions({ + queryKey: ['admin-consents', { consentID, params }, { canWriteAny }], + queryFn: async (): Promise => { + if (consentID) { + const consent = await fetchAdminConsentByID(consentID) + return { + rows: [toAdminConsentRow(consent, canWriteAny)], + total: 1, + } + } + const response = await fetchAdminConsents(params) + return { + rows: response.data.map((consent) => toAdminConsentRow(consent, canWriteAny)), + total: response.metadata.total, + } + }, + placeholderData: keepPreviousData, + }) +} + +export function useAdminConsentListQuery( + filters: AdminConsentRegistryFilters, + page: number, + rowsPerPage: number, + sortField: ConsentRegistrySortField, + sortDirection: ConsentRegistrySortDirection, +): UseQueryResult { + const queryClient = useQueryClient() + const { hasScope } = useAuthorization() + const canWriteAny = hasScope(PORTAL_SCOPES.CONSENTS_WRITE_ANY) + const query = useQuery( + adminConsentListQueryOptions(filters, page, rowsPerPage, sortField, sortDirection, canWriteAny), + ) + + useEffect(() => { + const nextPage = page + 1 + const hasNextPage = nextPage * rowsPerPage < (query.data?.total ?? 0) + if (!query.isPlaceholderData && hasNextPage) { + queryClient + .prefetchQuery( + adminConsentListQueryOptions( + filters, + nextPage, + rowsPerPage, + sortField, + sortDirection, + canWriteAny, + ), + ) + .catch(() => undefined) + } + }, [ + canWriteAny, + filters, + page, + query.data?.total, + query.isPlaceholderData, + queryClient, + rowsPerPage, + sortDirection, + sortField, + ]) + + return query +} + +export function useAdminConsentDetailQuery( + consentID: string | undefined, +): UseQueryResult { + return useQuery({ + queryKey: ['admin-consent', consentID], + queryFn: () => fetchAdminConsentByID(String(consentID)), + enabled: Boolean(consentID), + }) +} + +interface AdminRevokeVariables { + consentID: string + actionBy: string +} + +export function useAdminRevokeConsentMutation(): UseMutationResult< + unknown, + Error, + AdminRevokeVariables +> { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ consentID, actionBy }) => revokeAdminConsent(consentID, actionBy), + onSuccess: async (_data, variables) => { + await queryClient.invalidateQueries({ queryKey: ['admin-consents'] }) + await queryClient.invalidateQueries({ queryKey: ['admin-consent', variables.consentID] }) + }, + }) +} diff --git a/portal/frontend/src/features/admin-consents/utils/adminConsentFilters.ts b/portal/frontend/src/features/admin-consents/utils/adminConsentFilters.ts new file mode 100644 index 00000000..233b0434 --- /dev/null +++ b/portal/frontend/src/features/admin-consents/utils/adminConsentFilters.ts @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { AdminConsentRegistryFilters } from '../../../types/consent' + +export const EMPTY_ADMIN_CONSENT_FILTERS: AdminConsentRegistryFilters = { + status: 'All', + consentId: '', + purposeName: '', + purposeVersion: '', + userIds: '', + groupIds: '', + elementName: '', + elementNamespace: '', + elementVersion: '', + startDate: '', + endDate: '', +} + +const VALID_STATUSES: AdminConsentRegistryFilters['status'][] = [ + 'All', + 'Active', + 'Pending', + 'Rejected', + 'Revoked', + 'Expired', +] + +export function normalizeCommaSeparatedIDs(value: string): string { + return Array.from( + new Set( + value + .split(',') + .map((item) => item.trim()) + .filter(Boolean), + ), + ).join(',') +} + +export function normalizeAdminConsentFilters( + filters: AdminConsentRegistryFilters, +): AdminConsentRegistryFilters { + const purposeName = filters.purposeName.trim() + const elementName = filters.elementName.trim() + const elementNamespace = filters.elementNamespace.trim() + return { + ...filters, + consentId: filters.consentId.trim(), + purposeName, + purposeVersion: purposeName ? filters.purposeVersion.trim() : '', + userIds: normalizeCommaSeparatedIDs(filters.userIds), + groupIds: normalizeCommaSeparatedIDs(filters.groupIds), + elementName, + elementNamespace, + elementVersion: elementName || elementNamespace ? filters.elementVersion.trim() : '', + } +} + +export function getAdminConsentFilters(searchParams: URLSearchParams): AdminConsentRegistryFilters { + const status = searchParams.get('status') + return normalizeAdminConsentFilters({ + status: VALID_STATUSES.includes(status as AdminConsentRegistryFilters['status']) + ? (status as AdminConsentRegistryFilters['status']) + : 'All', + consentId: searchParams.get('consentId') ?? '', + purposeName: searchParams.get('purposeName') ?? '', + purposeVersion: searchParams.get('purposeVersion') ?? '', + userIds: searchParams.get('userIds') ?? '', + groupIds: searchParams.get('groupIds') ?? '', + elementName: searchParams.get('elementName') ?? '', + elementNamespace: searchParams.get('elementNamespace') ?? '', + elementVersion: searchParams.get('elementVersion') ?? '', + startDate: searchParams.get('startDate') ?? '', + endDate: searchParams.get('endDate') ?? '', + }) +} diff --git a/portal/frontend/src/features/auth/authorizationRoutes.ts b/portal/frontend/src/features/auth/authorizationRoutes.ts index 3e7955f5..7442b8f9 100644 --- a/portal/frontend/src/features/auth/authorizationRoutes.ts +++ b/portal/frontend/src/features/auth/authorizationRoutes.ts @@ -23,6 +23,7 @@ const AUTHORIZED_DESTINATIONS: ReadonlyArray<{ path: string; scope: PortalScope { path: '/consents', scope: PORTAL_SCOPES.CONSENTS_READ_SELF }, { path: '/purposes', scope: PORTAL_SCOPES.PURPOSES_READ }, { path: '/elements', scope: PORTAL_SCOPES.ELEMENTS_READ }, + { path: '/administration/consents', scope: PORTAL_SCOPES.CONSENTS_READ_ANY }, ] export default function firstAuthorizedPath(scopes: readonly PortalScope[]): string | undefined { diff --git a/portal/frontend/src/features/consent-registry/ConsentDetailsPage.tsx b/portal/frontend/src/features/consent-registry/ConsentDetailsPage.tsx index 468a9635..38d50e28 100644 --- a/portal/frontend/src/features/consent-registry/ConsentDetailsPage.tsx +++ b/portal/frontend/src/features/consent-registry/ConsentDetailsPage.tsx @@ -53,6 +53,14 @@ import { isConsentRevokableStatus, } from './utils/statusChip' import { PORTAL_SCOPES } from '../../utils/portalScopes' +import { + useAdminConsentDetailQuery, + useAdminRevokeConsentMutation, +} from '../admin-consents/hooks/useAdminConsentQueries' + +interface ConsentDetailsPageProps { + variant?: 'self' | 'admin' +} function formatResourcesForModal(resources: unknown): string { if (!resources) { @@ -150,21 +158,28 @@ function ConsentDetailsLoading(): React.JSX.Element { ) } -function ConsentDetailsPage(): React.JSX.Element { +function ConsentDetailsPage({ variant = 'self' }: ConsentDetailsPageProps): React.JSX.Element { const { t } = useTranslation('common') const { id } = useParams<{ id: string }>() const navigate = useNavigate() - const consentDetailQuery = useConsentDetailQuery(id) + const selfConsentDetailQuery = useConsentDetailQuery(variant === 'self' ? id : undefined) + const adminConsentDetailQuery = useAdminConsentDetailQuery(variant === 'admin' ? id : undefined) const approveMutation = useApproveConsentMutation() const rejectMutation = useRejectConsentMutation() const revokeMutation = useRevokeConsentMutation() + const adminRevokeMutation = useAdminRevokeConsentMutation() const [approvalDialogOpen, setApprovalDialogOpen] = useState(false) const [rejectionDialogOpen, setRejectionDialogOpen] = useState(false) const [revocationDialogOpen, setRevocationDialogOpen] = useState(false) const [resourcesModalOpen, setResourcesModalOpen] = useState(false) const [selectedResourcesJson, setSelectedResourcesJson] = useState('') - const { hasScope } = useAuthorization() + const { currentUser, hasScope } = useAuthorization() const canWriteSelf = hasScope(PORTAL_SCOPES.CONSENTS_WRITE_SELF) + const canWriteAny = hasScope(PORTAL_SCOPES.CONSENTS_WRITE_ANY) + const consentDetailQuery = variant === 'admin' ? adminConsentDetailQuery : selfConsentDetailQuery + const backPath = variant === 'admin' ? '/administration/consents' : '/consents' + const revokePending = + variant === 'admin' ? adminRevokeMutation.isPending : revokeMutation.isPending if (!id) { return ( @@ -174,7 +189,7 @@ function ConsentDetailsPage(): React.JSX.Element { > {t('consentRegistry.details.notFound')} - @@ -183,9 +198,13 @@ function ConsentDetailsPage(): React.JSX.Element { } const detail = consentDetailQuery.data - const canApprove = detail ? canWriteSelf && isConsentApprovableStatus(detail.status) : false - const canReject = detail ? canWriteSelf && isConsentRejectableStatus(detail.status) : false - const canRevoke = detail ? canWriteSelf && isConsentRevokableStatus(detail.status) : false + const canApprove = + variant === 'self' && detail ? canWriteSelf && isConsentApprovableStatus(detail.status) : false + const canReject = + variant === 'self' && detail ? canWriteSelf && isConsentRejectableStatus(detail.status) : false + const canRevoke = detail + ? (variant === 'admin' ? canWriteAny : canWriteSelf) && isConsentRevokableStatus(detail.status) + : false if (consentDetailQuery.isLoading) { return @@ -199,7 +218,7 @@ function ConsentDetailsPage(): React.JSX.Element { > {t('consentRegistry.messages.loadFailed')} - @@ -259,7 +278,7 @@ function ConsentDetailsPage(): React.JSX.Element { color="error" size="small" startIcon={} - disabled={revokeMutation.isPending} + disabled={revokePending} onClick={() => { setRevocationDialogOpen(true) }} @@ -331,20 +350,29 @@ function ConsentDetailsPage(): React.JSX.Element { key={`revocation-${id}-${String(revocationDialogOpen)}`} open={revocationDialogOpen} consentId={id} - loading={revokeMutation.isPending} + loading={revokePending} onClose={() => { setRevocationDialogOpen(false) }} onConfirm={() => { - revokeMutation.mutate(id, { + const options = { onSuccess: () => { setRevocationDialogOpen(false) }, - }) + } + if (variant === 'admin') { + adminRevokeMutation.mutate({ consentID: id, actionBy: currentUser.userId }, options) + } else { + revokeMutation.mutate(id, options) + } }} /> ) } +ConsentDetailsPage.defaultProps = { + variant: 'self', +} + export default ConsentDetailsPage diff --git a/portal/frontend/src/features/consent-registry/components/ConsentRegistryTable.tsx b/portal/frontend/src/features/consent-registry/components/ConsentRegistryTable.tsx index d9acbcd9..a84eece2 100644 --- a/portal/frontend/src/features/consent-registry/components/ConsentRegistryTable.tsx +++ b/portal/frontend/src/features/consent-registry/components/ConsentRegistryTable.tsx @@ -63,8 +63,11 @@ interface ConsentRegistryTableProps { onRowsPerPageChange: (rowsPerPage: number) => void onSortChange: (field: ConsentRegistrySortField, direction: ConsentRegistrySortDirection) => void onRetry: () => void - onApprove: (consentID: string) => void - onRevoke: (consentID: string) => void + detailBasePath?: string + showApproveAction?: boolean + showMutationActions?: boolean + onApprove?: (consentID: string) => void + onRevoke?: (consentID: string) => void isMutating: boolean } @@ -92,6 +95,9 @@ export default function ConsentRegistryTable({ onRowsPerPageChange, onSortChange, onRetry, + detailBasePath = '/consents', + showApproveAction = true, + showMutationActions = true, onApprove, onRevoke, isMutating, @@ -138,10 +144,10 @@ export default function ConsentRegistryTable({ const consentID = event.currentTarget.dataset.consentId if (consentID) { - navigate(`/consents/${encodeURIComponent(consentID)}`) + navigate(`${detailBasePath}/${encodeURIComponent(consentID)}`) } }, - [navigate], + [detailBasePath, navigate], ) const handleApproveClick = useCallback( @@ -150,7 +156,7 @@ export default function ConsentRegistryTable({ const consentID = event.currentTarget.dataset.consentId if (consentID) { - onApprove(consentID) + onApprove?.(consentID) } }, [onApprove], @@ -162,7 +168,7 @@ export default function ConsentRegistryTable({ const consentID = event.currentTarget.dataset.consentId if (consentID) { - onRevoke(consentID) + onRevoke?.(consentID) } }, [onRevoke], @@ -426,14 +432,14 @@ export default function ConsentRegistryTable({ - {row.canApprove ? ( + {showMutationActions && showApproveAction && row.canApprove ? ( - ) : ( + ) : null} + {showMutationActions && !(showApproveAction && row.canApprove) ? ( - )} + ) : null} @@ -562,3 +569,11 @@ export default function ConsentRegistryTable({ ) } + +ConsentRegistryTable.defaultProps = { + detailBasePath: '/consents', + showApproveAction: true, + showMutationActions: true, + onApprove: undefined, + onRevoke: undefined, +} diff --git a/portal/frontend/src/i18n/resources/en/common.ts b/portal/frontend/src/i18n/resources/en/common.ts index ccd93cbe..14de0aae 100644 --- a/portal/frontend/src/i18n/resources/en/common.ts +++ b/portal/frontend/src/i18n/resources/en/common.ts @@ -43,6 +43,8 @@ const commonEn = { catalog: 'Definitions', purposes: 'Purposes', elements: 'Elements', + administration: 'Administration', + adminConsents: 'Consents', }, layout: { home: 'Home', @@ -77,6 +79,19 @@ const commonEn = { noTypes: 'No consent types are available yet.', viewPending: 'View all pending consents', }, + adminConsents: { + title: 'Consents', + filters: { + sectionAriaLabel: 'Administrative consent filters', + consentIdSearchPlaceholder: 'Search by consent ID', + removeConsentIdForAdvanced: 'Remove the Consent ID filter to use advanced filters.', + removeConsentIdForStatus: 'Remove the Consent ID filter to use the status filter.', + userIds: 'User IDs', + groupIds: 'Group IDs', + commaSeparatedUsers: 'Separate multiple user IDs with commas', + active: 'Active filters', + }, + }, consentRegistry: { title: 'All Consents', details: { diff --git a/portal/frontend/src/types/consent.ts b/portal/frontend/src/types/consent.ts index e27cb3eb..d48b35fa 100644 --- a/portal/frontend/src/types/consent.ts +++ b/portal/frontend/src/types/consent.ts @@ -46,14 +46,26 @@ export interface ConsentRegistryFilters { endDate: string } +export interface AdminConsentRegistryFilters extends ConsentRegistryFilters { + consentId: string + userIds: string + purposeVersion: string + elementName: string + elementNamespace: string + elementVersion: string +} + export type ConsentRegistrySortField = 'status' | 'updatedTime' | 'validityTime' export type ConsentRegistrySortDirection = 'asc' | 'desc' export interface ConsentListQueryParams { consentStatuses?: string + userIds?: string purposeName?: string + purposeVersion?: string groupIds?: string elementName?: string + elementNamespace?: string elementVersion?: string sort?: string fromTime?: number From e8c40ddc408382a1b79b3cbc24323b351dd8f209 Mon Sep 17 00:00:00 2001 From: rashmithachamikara Date: Mon, 3 Aug 2026 01:37:23 +0530 Subject: [PATCH 6/7] Make ORG-ID configurable in portal backend --- portal/backend/.env.example | 2 ++ portal/backend/docs/README.md | 2 ++ .../backend/internal/system/auth/manager.go | 5 ++++- .../internal/system/auth/validation_test.go | 20 +++++++++++++++++ .../backend/internal/system/config/config.go | 8 ++++++- .../internal/system/config/config_test.go | 22 +++++++++++++++++++ 6 files changed, 57 insertions(+), 2 deletions(-) diff --git a/portal/backend/.env.example b/portal/backend/.env.example index 75b23fca..ab90aeb6 100644 --- a/portal/backend/.env.example +++ b/portal/backend/.env.example @@ -51,6 +51,8 @@ BFF_AUTH__REFRESH_TIMEOUT=10s BFF_AUTH__CLOCK_SKEW=30s BFF_AUTH__SCOPE_CLAIM=scope BFF_AUTH__ORG_ID_CLAIM=org_id +# Overrides the organization claim when non-empty. Leave empty to use BFF_AUTH__ORG_ID_CLAIM. +BFF_AUTH__ORG_ID_OVERRIDE=ORG-001 BFF_AUTH__REQUIRE_ACCESS_TOKEN_TYPE=false BFF_AUTH__ACCESS_TOKEN_TYPE_CLAIM=token_type BFF_AUTH__ACCESS_TOKEN_TYPE_VALUE=access_token diff --git a/portal/backend/docs/README.md b/portal/backend/docs/README.md index 054c6d52..17722d8d 100644 --- a/portal/backend/docs/README.md +++ b/portal/backend/docs/README.md @@ -138,6 +138,8 @@ Configuration is grouped into: Production startup requires authentication, HTTPS authentication URLs, and secure cookies. Placeholder identity cannot run in production and cannot be enabled together with OIDC authentication. In permitted local environments, placeholder mode supplies the configured user and organization without an identity provider. `BFF_PROXY__PLACEHOLDER_SCOPES` is a space-separated list of canonical portal scopes granted to that principal. Unknown or duplicate scopes are rejected, and an empty list creates an authenticated principal without portal permissions. Placeholder identity and scope values must be empty when placeholder mode is disabled. +For authenticated deployments, the organization normally comes from the access-token claim named by `BFF_AUTH__ORG_ID_CLAIM`. Set `BFF_AUTH__ORG_ID_OVERRIDE` to force a fixed organization ID instead; a non-empty override takes precedence and the token does not need the organization claim. Leave the override empty to keep claim-based resolution. + ### Local topology | Service | Typical local address | diff --git a/portal/backend/internal/system/auth/manager.go b/portal/backend/internal/system/auth/manager.go index 2ec59a7d..b87387e5 100644 --- a/portal/backend/internal/system/auth/manager.go +++ b/portal/backend/internal/system/auth/manager.go @@ -221,7 +221,10 @@ func (m *Manager) validateAccessToken(ctx context.Context, raw string) (systemco return systemcontext.Principal{}, time.Time{}, errInvalidCredentials } subject := strings.TrimSpace(verified.Subject) - orgID, err := stringClaim(claims, m.cfg.OrgIDClaim) + orgID := strings.TrimSpace(m.cfg.OrgIDOverride) + if orgID == "" { + orgID, err = stringClaim(claims, m.cfg.OrgIDClaim) + } if err != nil || subject == "" || orgID == "" { return systemcontext.Principal{}, time.Time{}, errInvalidCredentials } diff --git a/portal/backend/internal/system/auth/validation_test.go b/portal/backend/internal/system/auth/validation_test.go index b6bd3ef1..509a4ab5 100644 --- a/portal/backend/internal/system/auth/validation_test.go +++ b/portal/backend/internal/system/auth/validation_test.go @@ -198,6 +198,26 @@ func TestAccessTokenArrayScopeClaim(t *testing.T) { } } +func TestAccessTokenOrganizationOverride(t *testing.T) { + p := newOIDCValidationProvider(t) + manager := p.manager(t, func(cfg *config.AuthConfig) { + cfg.OrgIDOverride = " ORG-001 " + }) + now := time.Now() + raw := signTestToken(t, p.key1, "key-1", map[string]any{ + "iss": p.server.URL, "sub": "user", "aud": "portal-api", "exp": now.Add(time.Hour).Unix(), + "scope": ScopeConsentsReadSelf, + }) + + principal, _, err := manager.validateAccessToken(context.Background(), raw) + if err != nil { + t.Fatalf("expected token without organization claim to use override: %v", err) + } + if principal.OrgID != "ORG-001" { + t.Fatalf("unexpected organization ID: %q", principal.OrgID) + } +} + func TestIDTokenValidationFailures(t *testing.T) { p := newOIDCValidationProvider(t) manager := p.manager(t, nil) diff --git a/portal/backend/internal/system/config/config.go b/portal/backend/internal/system/config/config.go index d5d33b1d..a90c8b27 100644 --- a/portal/backend/internal/system/config/config.go +++ b/portal/backend/internal/system/config/config.go @@ -60,6 +60,7 @@ type AuthConfig struct { RefreshTimeout time.Duration `koanf:"refresh_timeout"` ScopeClaim string `koanf:"scope_claim"` OrgIDClaim string `koanf:"org_id_claim"` + OrgIDOverride string `koanf:"org_id_override"` RequireAccessTokenType bool `koanf:"require_access_token_type"` AccessTokenTypeClaim string `koanf:"access_token_type_claim"` AccessTokenTypeValue string `koanf:"access_token_type_value"` @@ -242,6 +243,9 @@ func setDefaults(k *koanf.Koanf) error { if err := k.Set("auth.org_id_claim", "org_id"); err != nil { return err } + if err := k.Set("auth.org_id_override", ""); err != nil { + return err + } if err := k.Set("auth.require_access_token_type", false); err != nil { return err } @@ -419,7 +423,9 @@ func validateAuth(cfg Config) error { "auth.post_logout_redirect_uri": cfg.Auth.PostLogoutRedirectURI, "auth.resource_audience": cfg.Auth.ResourceAudience, "auth.scope_claim": cfg.Auth.ScopeClaim, - "auth.org_id_claim": cfg.Auth.OrgIDClaim, + } + if strings.TrimSpace(cfg.Auth.OrgIDOverride) == "" { + required["auth.org_id_claim"] = cfg.Auth.OrgIDClaim } for name, value := range required { if strings.TrimSpace(value) == "" { diff --git a/portal/backend/internal/system/config/config_test.go b/portal/backend/internal/system/config/config_test.go index dbdbb225..3580f8d5 100644 --- a/portal/backend/internal/system/config/config_test.go +++ b/portal/backend/internal/system/config/config_test.go @@ -56,6 +56,28 @@ func TestLoadFromEnv(t *testing.T) { } } +func TestOrgIDOverrideFromEnv(t *testing.T) { + t.Setenv("BFF_AUTH__ORG_ID_OVERRIDE", "ORG-001") + + cfg, err := Load() + if err != nil { + t.Fatalf("expected config to load, got error: %v", err) + } + if cfg.Auth.OrgIDOverride != "ORG-001" { + t.Fatalf("unexpected organization override: %q", cfg.Auth.OrgIDOverride) + } +} + +func TestOrgIDClaimCanBeEmptyWhenOverrideIsConfigured(t *testing.T) { + cfg := validAuthValidationConfig("development") + cfg.Auth.OrgIDClaim = "" + cfg.Auth.OrgIDOverride = "ORG-001" + + if err := validateAuth(cfg); err != nil { + t.Fatalf("expected organization override to replace claim requirement: %v", err) + } +} + func TestInvalidCORSOriginRejected(t *testing.T) { tests := []struct { name string From 0b0d50036cde17802caff3a12845a4bac7d1b7d7 Mon Sep 17 00:00:00 2001 From: rashmithachamikara Date: Mon, 3 Aug 2026 03:36:44 +0530 Subject: [PATCH 7/7] Add audience to the OIDC authorization request. --- portal/backend/internal/system/auth/handlers.go | 6 +++++- portal/backend/internal/system/auth/handlers_test.go | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/portal/backend/internal/system/auth/handlers.go b/portal/backend/internal/system/auth/handlers.go index fc8745c2..6fba9797 100644 --- a/portal/backend/internal/system/auth/handlers.go +++ b/portal/backend/internal/system/auth/handlers.go @@ -50,7 +50,11 @@ func (m *Manager) Login(w http.ResponseWriter, r *http.Request) { return } m.setLoginTransactionCookies(w, state, verifier) - authorizationURL := m.oauthConfig.AuthCodeURL(state, pkceAuthorizationOption(verifier)) + authorizationURL := m.oauthConfig.AuthCodeURL( + state, + pkceAuthorizationOption(verifier), + oauth2.SetAuthURLParam("audience", m.cfg.ResourceAudience), + ) http.Redirect(w, r, authorizationURL, http.StatusFound) } diff --git a/portal/backend/internal/system/auth/handlers_test.go b/portal/backend/internal/system/auth/handlers_test.go index 9edba2c4..c5e842be 100644 --- a/portal/backend/internal/system/auth/handlers_test.go +++ b/portal/backend/internal/system/auth/handlers_test.go @@ -115,6 +115,9 @@ func TestOIDCLoginCallbackRefreshAndLogout(t *testing.T) { } state := authorizationURL.Query().Get("state") expectedPKCEChallenge = authorizationURL.Query().Get("code_challenge") + if audience := authorizationURL.Query().Get("audience"); audience != cfg.ResourceAudience { + t.Fatalf("unexpected authorization audience: %q", audience) + } if state == "" || expectedPKCEChallenge == "" || authorizationURL.Query().Get("code_challenge_method") != "S256" { t.Fatalf("login redirect missing state or S256 PKCE parameters: %s", authorizationURL.RawQuery) }