From 43fc09d99ec21e5659bab2a02b4cba0962e9e97f Mon Sep 17 00:00:00 2001 From: dushaniw Date: Wed, 12 Aug 2026 13:18:18 +0530 Subject: [PATCH 01/25] add api portal schema for postgres. --- .../internal/database/schema.postgres.sql | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/platform-api/internal/database/schema.postgres.sql b/platform-api/internal/database/schema.postgres.sql index a37fb640bc..d0a2912255 100644 --- a/platform-api/internal/database/schema.postgres.sql +++ b/platform-api/internal/database/schema.postgres.sql @@ -399,6 +399,26 @@ CREATE TABLE IF NOT EXISTS mcp_proxies ( UNIQUE(organization_uuid, handle) ); +-- API Portals table (registration of an API Portal instance for an organization) +CREATE TABLE IF NOT EXISTS api_portals ( + uuid VARCHAR(40) PRIMARY KEY, + organization_uuid VARCHAR(40) NOT NULL, + handle VARCHAR(40) NOT NULL, + display_name VARCHAR(255) NOT NULL, + description VARCHAR(1023), + url VARCHAR(500), + workflow_status VARCHAR(20) NOT NULL DEFAULT 'pending', + auth_type VARCHAR(20) NOT NULL, + configuration BYTEA NOT NULL, + data_version VARCHAR(20) NOT NULL DEFAULT '1.0', + created_by VARCHAR(200), + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_by VARCHAR(200), + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, + UNIQUE (organization_uuid, handle) +); + CREATE TABLE IF NOT EXISTS api_keys ( uuid VARCHAR(40) PRIMARY KEY, @@ -473,6 +493,7 @@ CREATE INDEX IF NOT EXISTS idx_llm_proxies_provider_uuid ON llm_proxies(provider CREATE INDEX IF NOT EXISTS idx_llm_proxies_org ON llm_proxies(organization_uuid); CREATE INDEX IF NOT EXISTS idx_mcp_proxies_project ON mcp_proxies(project_uuid); CREATE INDEX IF NOT EXISTS idx_mcp_proxies_org ON mcp_proxies(organization_uuid); +CREATE INDEX IF NOT EXISTS idx_api_portals_org ON api_portals(organization_uuid); CREATE INDEX IF NOT EXISTS idx_api_keys_artifact ON api_keys(artifact_uuid); CREATE INDEX IF NOT EXISTS idx_applications_org ON applications(organization_uuid); CREATE INDEX IF NOT EXISTS idx_applications_project_id ON applications(organization_uuid, project_uuid); From d9aff4bc57964abe449c2dd50441e98d05622a74 Mon Sep 17 00:00:00 2001 From: dushaniw Date: Wed, 12 Aug 2026 13:26:15 +0530 Subject: [PATCH 02/25] database schema. --- .../internal/database/schema.sqlite.sql | 21 +++++++++++++++++ .../internal/database/schema.sqlserver.sql | 23 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/platform-api/internal/database/schema.sqlite.sql b/platform-api/internal/database/schema.sqlite.sql index 9f009f6262..315d7efbd4 100644 --- a/platform-api/internal/database/schema.sqlite.sql +++ b/platform-api/internal/database/schema.sqlite.sql @@ -399,6 +399,26 @@ CREATE TABLE IF NOT EXISTS mcp_proxies ( UNIQUE(organization_uuid, handle) ); +-- API Portals table (registration of an API Portal instance for an organization) +CREATE TABLE IF NOT EXISTS api_portals ( + uuid VARCHAR(40) PRIMARY KEY, + organization_uuid VARCHAR(40) NOT NULL, + handle VARCHAR(40) NOT NULL, + display_name VARCHAR(255) NOT NULL, + description VARCHAR(1023), + url VARCHAR(500), + workflow_status VARCHAR(20) NOT NULL DEFAULT 'pending', + auth_type VARCHAR(20) NOT NULL, + configuration BLOB NOT NULL, + data_version VARCHAR(20) NOT NULL DEFAULT '1.0', + created_by VARCHAR(200), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_by VARCHAR(200), + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, + UNIQUE (organization_uuid, handle) +); + -- API Keys table (stores API keys for artifacts with hashes as JSON string) CREATE TABLE IF NOT EXISTS api_keys ( uuid VARCHAR(40) PRIMARY KEY, @@ -472,6 +492,7 @@ CREATE INDEX IF NOT EXISTS idx_llm_proxies_provider_uuid ON llm_proxies(provider CREATE INDEX IF NOT EXISTS idx_llm_proxies_org ON llm_proxies(organization_uuid); CREATE INDEX IF NOT EXISTS idx_mcp_proxies_project ON mcp_proxies(project_uuid); CREATE INDEX IF NOT EXISTS idx_mcp_proxies_org ON mcp_proxies(organization_uuid); +CREATE INDEX IF NOT EXISTS idx_api_portals_org ON api_portals(organization_uuid); CREATE INDEX IF NOT EXISTS idx_api_keys_artifact ON api_keys(artifact_uuid); CREATE INDEX IF NOT EXISTS idx_rest_apis_org ON rest_apis(organization_uuid); CREATE INDEX IF NOT EXISTS idx_applications_org ON applications(organization_uuid); diff --git a/platform-api/internal/database/schema.sqlserver.sql b/platform-api/internal/database/schema.sqlserver.sql index 454a229575..e06a88b70e 100644 --- a/platform-api/internal/database/schema.sqlserver.sql +++ b/platform-api/internal/database/schema.sqlserver.sql @@ -450,6 +450,27 @@ CREATE TABLE dbo.mcp_proxies ( UNIQUE(organization_uuid, handle) ); +-- API Portals table (registration of an API Portal instance for an organization) +IF OBJECT_ID(N'dbo.api_portals', N'U') IS NULL +CREATE TABLE dbo.api_portals ( + uuid VARCHAR(40) PRIMARY KEY, + organization_uuid VARCHAR(40) NOT NULL, + handle VARCHAR(40) NOT NULL, + display_name VARCHAR(255) NOT NULL, + description VARCHAR(1023), + url VARCHAR(500), + workflow_status VARCHAR(20) NOT NULL DEFAULT 'pending', + auth_type VARCHAR(20) NOT NULL, + configuration VARBINARY(MAX) NOT NULL, + data_version VARCHAR(20) NOT NULL DEFAULT '1.0', + created_by VARCHAR(200), + created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), + updated_by VARCHAR(200), + updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), + FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, + UNIQUE (organization_uuid, handle) +); + IF OBJECT_ID(N'dbo.api_keys', N'U') IS NULL CREATE TABLE dbo.api_keys ( uuid VARCHAR(40) PRIMARY KEY, @@ -554,6 +575,8 @@ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_mcp_proxies_project' CREATE INDEX idx_mcp_proxies_project ON dbo.mcp_proxies(project_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_mcp_proxies_org' AND object_id = OBJECT_ID(N'dbo.mcp_proxies')) CREATE INDEX idx_mcp_proxies_org ON dbo.mcp_proxies(organization_uuid); +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_portals_org' AND object_id = OBJECT_ID(N'dbo.api_portals')) +CREATE INDEX idx_api_portals_org ON dbo.api_portals(organization_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_keys_artifact' AND object_id = OBJECT_ID(N'dbo.api_keys')) CREATE INDEX idx_api_keys_artifact ON dbo.api_keys(artifact_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_keys_status' AND object_id = OBJECT_ID(N'dbo.api_keys')) From feb757aa55e3e3a8a91eeb531e63134db58c6971 Mon Sep 17 00:00:00 2001 From: dushaniw Date: Wed, 12 Aug 2026 16:06:44 +0530 Subject: [PATCH 03/25] add dao models for devportal. --- platform-api/internal/constants/constants.go | 26 ++++++++ platform-api/internal/model/api_portal.go | 65 ++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 platform-api/internal/model/api_portal.go diff --git a/platform-api/internal/constants/constants.go b/platform-api/internal/constants/constants.go index 00330f75ae..b8ae64833f 100644 --- a/platform-api/internal/constants/constants.go +++ b/platform-api/internal/constants/constants.go @@ -207,6 +207,32 @@ var ValidGatewayTokenStatuses = map[string]bool{ GatewayTokenStatusRevoked: true, } +// API Portal workflow status constants +const ( + APIPortalWorkflowStatusPending = "pending" + APIPortalWorkflowStatusActive = "active" + APIPortalWorkflowStatusFailed = "failed" +) + +// ValidAPIPortalWorkflowStatuses holds accepted values for api_portals.workflow_status +var ValidAPIPortalWorkflowStatuses = map[string]bool{ + APIPortalWorkflowStatusPending: true, + APIPortalWorkflowStatusActive: true, + APIPortalWorkflowStatusFailed: true, +} + +// API Portal auth type constants +const ( + APIPortalAuthTypeLocal = "local" + APIPortalAuthTypeOAuth2 = "oauth2" +) + +// ValidAPIPortalAuthTypes holds accepted values for api_portals.auth_type +var ValidAPIPortalAuthTypes = map[string]bool{ + APIPortalAuthTypeLocal: true, + APIPortalAuthTypeOAuth2: true, +} + // ValidArtifactKinds holds accepted values for artifacts.type for the core (non-plugin) // artifact kinds. Plugin-owned kinds (e.g. WebSubApi, WebBrokerApi) are registered // into the ArtifactTableRegistry during plugin Init. diff --git a/platform-api/internal/model/api_portal.go b/platform-api/internal/model/api_portal.go new file mode 100644 index 0000000000..4807211f8f --- /dev/null +++ b/platform-api/internal/model/api_portal.go @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed 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 model + +import ( + "time" + + "github.com/wso2/api-platform/platform-api/internal/constants" +) + +// APIPortal represents an API Portal registered within an organization. +// The Configuration blob carries auth-type-specific fields: +// - auth_type=local : may hold key material references (local JWT minting). +// - auth_type=oauth2 : holds STS token URL, client credentials, optional audience. +type APIPortal struct { + ID string `json:"id" db:"uuid"` + OrganizationID string `json:"organizationId" db:"organization_uuid"` + Handle string `json:"handle" db:"handle"` + Name string `json:"name" db:"display_name"` + Description string `json:"description,omitempty" db:"description"` + URL string `json:"url,omitempty" db:"url"` + WorkflowStatus string `json:"workflowStatus" db:"workflow_status"` + AuthType string `json:"authType" db:"auth_type"` + Configuration map[string]interface{} `json:"configuration,omitempty" db:"configuration"` + DataVersion string `json:"-" db:"data_version"` + CreatedBy string `json:"createdBy,omitempty" db:"created_by"` + UpdatedBy string `json:"updatedBy,omitempty" db:"updated_by"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` +} + +// TableName returns the table name for the APIPortal model. +func (APIPortal) TableName() string { + return "api_portals" +} + +// IsPending returns true if the portal is still being provisioned or activated. +func (p *APIPortal) IsPending() bool { + return p.WorkflowStatus == constants.APIPortalWorkflowStatusPending +} + +// IsActive returns true if the portal is reachable and functional. +func (p *APIPortal) IsActive() bool { + return p.WorkflowStatus == constants.APIPortalWorkflowStatusActive +} + +// IsFailed returns true if provisioning or a subsequent health check has failed. +func (p *APIPortal) IsFailed() bool { + return p.WorkflowStatus == constants.APIPortalWorkflowStatusFailed +} From ef9ce1d58921fb217b663cf78f58b1677cb457a5 Mon Sep 17 00:00:00 2001 From: dushaniw Date: Thu, 13 Aug 2026 00:28:04 +0530 Subject: [PATCH 04/25] remove data_version. add dao code. --- .../internal/database/schema.postgres.sql | 1 - .../internal/database/schema.sqlite.sql | 1 - .../internal/database/schema.sqlserver.sql | 1 - platform-api/internal/model/api_portal.go | 1 - .../internal/repository/api_portal.go | 274 +++++++++ .../internal/repository/api_portal_test.go | 538 ++++++++++++++++++ .../internal/repository/interfaces.go | 19 + 7 files changed, 831 insertions(+), 4 deletions(-) create mode 100644 platform-api/internal/repository/api_portal.go create mode 100644 platform-api/internal/repository/api_portal_test.go diff --git a/platform-api/internal/database/schema.postgres.sql b/platform-api/internal/database/schema.postgres.sql index d0a2912255..ac041d99ab 100644 --- a/platform-api/internal/database/schema.postgres.sql +++ b/platform-api/internal/database/schema.postgres.sql @@ -410,7 +410,6 @@ CREATE TABLE IF NOT EXISTS api_portals ( workflow_status VARCHAR(20) NOT NULL DEFAULT 'pending', auth_type VARCHAR(20) NOT NULL, configuration BYTEA NOT NULL, - data_version VARCHAR(20) NOT NULL DEFAULT '1.0', created_by VARCHAR(200), created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(200), diff --git a/platform-api/internal/database/schema.sqlite.sql b/platform-api/internal/database/schema.sqlite.sql index 315d7efbd4..652377867b 100644 --- a/platform-api/internal/database/schema.sqlite.sql +++ b/platform-api/internal/database/schema.sqlite.sql @@ -410,7 +410,6 @@ CREATE TABLE IF NOT EXISTS api_portals ( workflow_status VARCHAR(20) NOT NULL DEFAULT 'pending', auth_type VARCHAR(20) NOT NULL, configuration BLOB NOT NULL, - data_version VARCHAR(20) NOT NULL DEFAULT '1.0', created_by VARCHAR(200), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(200), diff --git a/platform-api/internal/database/schema.sqlserver.sql b/platform-api/internal/database/schema.sqlserver.sql index e06a88b70e..410a7a154c 100644 --- a/platform-api/internal/database/schema.sqlserver.sql +++ b/platform-api/internal/database/schema.sqlserver.sql @@ -462,7 +462,6 @@ CREATE TABLE dbo.api_portals ( workflow_status VARCHAR(20) NOT NULL DEFAULT 'pending', auth_type VARCHAR(20) NOT NULL, configuration VARBINARY(MAX) NOT NULL, - data_version VARCHAR(20) NOT NULL DEFAULT '1.0', created_by VARCHAR(200), created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), updated_by VARCHAR(200), diff --git a/platform-api/internal/model/api_portal.go b/platform-api/internal/model/api_portal.go index 4807211f8f..cbc24f151e 100644 --- a/platform-api/internal/model/api_portal.go +++ b/platform-api/internal/model/api_portal.go @@ -37,7 +37,6 @@ type APIPortal struct { WorkflowStatus string `json:"workflowStatus" db:"workflow_status"` AuthType string `json:"authType" db:"auth_type"` Configuration map[string]interface{} `json:"configuration,omitempty" db:"configuration"` - DataVersion string `json:"-" db:"data_version"` CreatedBy string `json:"createdBy,omitempty" db:"created_by"` UpdatedBy string `json:"updatedBy,omitempty" db:"updated_by"` CreatedAt time.Time `json:"createdAt" db:"created_at"` diff --git a/platform-api/internal/repository/api_portal.go b/platform-api/internal/repository/api_portal.go new file mode 100644 index 0000000000..ca4195cd31 --- /dev/null +++ b/platform-api/internal/repository/api_portal.go @@ -0,0 +1,274 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed 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 repository + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/wso2/api-platform/platform-api/internal/database" + "github.com/wso2/api-platform/platform-api/internal/model" +) + +// APIPortalRepo implements APIPortalRepository. +type APIPortalRepo struct { + db *database.DB +} + +// NewAPIPortalRepo creates a new API Portal repository. +func NewAPIPortalRepo(db *database.DB) APIPortalRepository { + return &APIPortalRepo{db: db} +} + +// apiPortalSelectColumns are the api_portals columns selected in every query, in scan order. +const apiPortalSelectColumns = ` + uuid, organization_uuid, handle, display_name, description, url, + workflow_status, auth_type, configuration, + created_by, updated_by, created_at, updated_at +` + +// scanAPIPortalRow scans one api_portals row using the column order in apiPortalSelectColumns. +func scanAPIPortalRow(scanner interface { + Scan(dest ...interface{}) error +}) (*model.APIPortal, error) { + portal := &model.APIPortal{} + var description, url, createdBy, updatedBy sql.NullString + var configurationBytes []byte + if err := scanner.Scan( + &portal.ID, &portal.OrganizationID, &portal.Handle, &portal.Name, &description, &url, + &portal.WorkflowStatus, &portal.AuthType, &configurationBytes, + &createdBy, &updatedBy, &portal.CreatedAt, &portal.UpdatedAt, + ); err != nil { + return nil, err + } + portal.Description = description.String + portal.URL = url.String + portal.CreatedBy = createdBy.String + portal.UpdatedBy = updatedBy.String + if len(configurationBytes) > 0 { + if err := json.Unmarshal(configurationBytes, &portal.Configuration); err != nil { + return nil, fmt.Errorf("failed to unmarshal configuration: %w", err) + } + } + // Normalize to a non-nil empty map so callers can range/read/write without + // nil-guarding. Handles both the empty-bytes case (defensive) and the + // unlikely case where json.Unmarshal returns a nil map. + if portal.Configuration == nil { + portal.Configuration = map[string]interface{}{} + } + return portal, nil +} + +// marshalAPIPortalConfiguration serializes the configuration map to JSON bytes for the +// configuration BYTEA/BLOB/VARBINARY column. A nil map is stored as an empty JSON object +// so the NOT NULL column always has valid content; readers (scanAPIPortalRow) mirror +// this by normalizing empty/{} back to an empty map, keeping the round-trip stable. +func marshalAPIPortalConfiguration(cfg map[string]interface{}) ([]byte, error) { + if cfg == nil { + return []byte("{}"), nil + } + b, err := json.Marshal(cfg) + if err != nil { + return nil, fmt.Errorf("failed to marshal configuration: %w", err) + } + return b, nil +} + +// Create inserts a new API Portal row. +func (r *APIPortalRepo) Create(portal *model.APIPortal) error { + now := time.Now().UTC() + portal.CreatedAt = now + portal.UpdatedAt = now + configBytes, err := marshalAPIPortalConfiguration(portal.Configuration) + if err != nil { + return err + } + query := ` + INSERT INTO api_portals (uuid, organization_uuid, handle, display_name, description, url, + workflow_status, auth_type, configuration, + created_by, updated_by, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + _, err = r.db.Exec(r.db.Rebind(query), + portal.ID, portal.OrganizationID, portal.Handle, portal.Name, portal.Description, portal.URL, + portal.WorkflowStatus, portal.AuthType, configBytes, + portal.CreatedBy, portal.UpdatedBy, portal.CreatedAt, portal.UpdatedAt, + ) + return err +} + +// GetByUUID retrieves an API Portal by its internal UUID, scoped to the organization. +func (r *APIPortalRepo) GetByUUID(portalID, orgUUID string) (*model.APIPortal, error) { + query := fmt.Sprintf(` + SELECT %s FROM api_portals + WHERE uuid = ? AND organization_uuid = ? + `, apiPortalSelectColumns) + row := r.db.QueryRow(r.db.Rebind(query), portalID, orgUUID) + portal, err := scanAPIPortalRow(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return portal, nil +} + +// GetByHandleAndOrgID retrieves an API Portal by its handle within an organization. +func (r *APIPortalRepo) GetByHandleAndOrgID(handle, orgUUID string) (*model.APIPortal, error) { + query := fmt.Sprintf(` + SELECT %s FROM api_portals + WHERE handle = ? AND organization_uuid = ? + `, apiPortalSelectColumns) + row := r.db.QueryRow(r.db.Rebind(query), handle, orgUUID) + portal, err := scanAPIPortalRow(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return portal, nil +} + +// ListPaginated returns a page of API Portals scoped to the organization, +// optionally filtered by workflow_status. +func (r *APIPortalRepo) ListPaginated(orgUUID string, workflowStatus *string, opts ListOptions) ([]*model.APIPortal, error) { + var args []interface{} + conditions := []string{`organization_uuid = ?`} + args = append(args, orgUUID) + if workflowStatus != nil { + conditions = append(conditions, `workflow_status = ?`) + args = append(args, *workflowStatus) + } + if searchClause, searchArgs := handleSearchClause(opts.Search); searchClause != "" { + conditions = append(conditions, strings.TrimPrefix(searchClause, " AND ")) + args = append(args, searchArgs...) + } + col, dir := opts.resolveSort(listSortColumns, "created_at") + pageClause, pageArgs := r.db.PaginationClause(opts.Limit, opts.Offset) + args = append(args, pageArgs...) + + query := fmt.Sprintf(` + SELECT %s FROM api_portals + WHERE %s + ORDER BY %s %s, handle ASC + %s + `, apiPortalSelectColumns, strings.Join(conditions, ` AND `), col, dir, pageClause) + + rows, err := r.db.Query(r.db.Rebind(query), args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var portals []*model.APIPortal + for rows.Next() { + portal, err := scanAPIPortalRow(rows) + if err != nil { + return nil, err + } + portals = append(portals, portal) + } + return portals, rows.Err() +} + +// Count returns the total number of API Portals matching the filter, independent of pagination. +func (r *APIPortalRepo) Count(orgUUID string, workflowStatus *string, search string) (int, error) { + var args []interface{} + conditions := []string{`organization_uuid = ?`} + args = append(args, orgUUID) + if workflowStatus != nil { + conditions = append(conditions, `workflow_status = ?`) + args = append(args, *workflowStatus) + } + if searchClause, searchArgs := handleSearchClause(search); searchClause != "" { + conditions = append(conditions, strings.TrimPrefix(searchClause, " AND ")) + args = append(args, searchArgs...) + } + query := `SELECT COUNT(*) FROM api_portals WHERE ` + strings.Join(conditions, ` AND `) + var total int + if err := r.db.QueryRow(r.db.Rebind(query), args...).Scan(&total); err != nil { + return 0, err + } + return total, nil +} + +// Update mutates only the whitelisted fields; immutable columns (uuid, organization_uuid, +// handle, data_version, created_by, created_at) are never touched. The caller is +// responsible for populating UpdatedBy before invoking. +func (r *APIPortalRepo) Update(portal *model.APIPortal) error { + portal.UpdatedAt = time.Now().UTC() + configBytes, err := marshalAPIPortalConfiguration(portal.Configuration) + if err != nil { + return err + } + query := ` + UPDATE api_portals + SET display_name = ?, description = ?, url = ?, workflow_status = ?, + auth_type = ?, configuration = ?, updated_by = ?, updated_at = ? + WHERE uuid = ? AND organization_uuid = ? + ` + result, err := r.db.Exec(r.db.Rebind(query), + portal.Name, portal.Description, portal.URL, portal.WorkflowStatus, + portal.AuthType, configBytes, portal.UpdatedBy, portal.UpdatedAt, + portal.ID, portal.OrganizationID, + ) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return fmt.Errorf("api portal not found: uuid=%q organization_uuid=%q", portal.ID, portal.OrganizationID) + } + return nil +} + +// Delete removes an API Portal row with organization isolation. +func (r *APIPortalRepo) Delete(portalID, orgUUID string) error { + query := `DELETE FROM api_portals WHERE uuid = ? AND organization_uuid = ?` + result, err := r.db.Exec(r.db.Rebind(query), portalID, orgUUID) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return fmt.Errorf("api portal not found: uuid=%q organization_uuid=%q", portalID, orgUUID) + } + return nil +} + +// Exists reports whether an API Portal with the given handle exists in the organization. +func (r *APIPortalRepo) Exists(handle, orgUUID string) (bool, error) { + var count int + query := `SELECT COUNT(*) FROM api_portals WHERE handle = ? AND organization_uuid = ?` + if err := r.db.QueryRow(r.db.Rebind(query), handle, orgUUID).Scan(&count); err != nil { + return false, err + } + return count > 0, nil +} diff --git a/platform-api/internal/repository/api_portal_test.go b/platform-api/internal/repository/api_portal_test.go new file mode 100644 index 0000000000..4781ecfb2a --- /dev/null +++ b/platform-api/internal/repository/api_portal_test.go @@ -0,0 +1,538 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed 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 repository + +import ( + "strings" + "testing" + "time" + + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/database" + "github.com/wso2/api-platform/platform-api/internal/model" +) + +// createTestAPIPortalOrg inserts the organization row api_portals references via its FK. +// The organizations table has no other prerequisite so this is a single INSERT. +func createTestAPIPortalOrg(t *testing.T, db *database.DB, orgUUID string) { + t.Helper() + q := ` + INSERT INTO organizations (uuid, handle, display_name, region, idp_organization_ref_uuid, created_at, updated_at) + VALUES (?, ?, ?, 'default', 'idp-ref', datetime('now'), datetime('now')) + ` + if _, err := db.Exec(q, orgUUID, "test-org-"+orgUUID, "Test Org"); err != nil { + t.Fatalf("failed to insert test organization: %v", err) + } +} + +// newTestAPIPortal returns a valid *model.APIPortal with sensible defaults. +// Individual tests override the fields they care about. +func newTestAPIPortal(uuid, orgUUID, handle string) *model.APIPortal { + return &model.APIPortal{ + ID: uuid, + OrganizationID: orgUUID, + Handle: handle, + Name: "Portal " + handle, + Description: "test portal", + URL: "https://" + handle + ".example.com", + WorkflowStatus: constants.APIPortalWorkflowStatusPending, + AuthType: constants.APIPortalAuthTypeLocal, + Configuration: map[string]interface{}{"foo": "bar"}, + CreatedBy: "tester", + UpdatedBy: "tester", + } +} + +func TestAPIPortalRepo_CreateAndGet(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgUUID = "org-portal-crud" + createTestAPIPortalOrg(t, db, orgUUID) + + repo := NewAPIPortalRepo(db) + portal := newTestAPIPortal("portal-001", orgUUID, "acme") + if err := repo.Create(portal); err != nil { + t.Fatalf("Create: %v", err) + } + + // Get by UUID. + got, err := repo.GetByUUID(portal.ID, orgUUID) + if err != nil { + t.Fatalf("GetByUUID: %v", err) + } + if got == nil { + t.Fatal("GetByUUID: expected row, got nil") + } + if got.Handle != portal.Handle || got.Name != portal.Name || got.URL != portal.URL { + t.Errorf("GetByUUID: field mismatch; got %+v", got) + } + if got.Configuration["foo"] != "bar" { + t.Errorf("configuration not round-tripped; got %v", got.Configuration) + } + + // Get by handle. + got2, err := repo.GetByHandleAndOrgID(portal.Handle, orgUUID) + if err != nil { + t.Fatalf("GetByHandleAndOrgID: %v", err) + } + if got2 == nil || got2.ID != portal.ID { + t.Errorf("GetByHandleAndOrgID mismatch; got %+v", got2) + } +} + +func TestAPIPortalRepo_Create_SetsDefaults(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgUUID = "org-portal-defaults" + createTestAPIPortalOrg(t, db, orgUUID) + + repo := NewAPIPortalRepo(db) + portal := newTestAPIPortal("portal-defaults", orgUUID, "defaults") + // Explicitly leave timestamps zero; expect Create to populate them. + portal.CreatedAt = time.Time{} + portal.UpdatedAt = time.Time{} + + before := time.Now().UTC().Add(-time.Second) + if err := repo.Create(portal); err != nil { + t.Fatalf("Create: %v", err) + } + after := time.Now().UTC().Add(time.Second) + + if portal.CreatedAt.Before(before) || portal.CreatedAt.After(after) { + t.Errorf("CreatedAt not set to ~now: got %v", portal.CreatedAt) + } + if portal.UpdatedAt.Before(before) || portal.UpdatedAt.After(after) { + t.Errorf("UpdatedAt not set to ~now: got %v", portal.UpdatedAt) + } +} + +func TestAPIPortalRepo_Create_ConfigurationRoundTrip_Nil(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgUUID = "org-portal-cfg-nil" + createTestAPIPortalOrg(t, db, orgUUID) + + repo := NewAPIPortalRepo(db) + portal := newTestAPIPortal("portal-cfg-nil", orgUUID, "cfg-nil") + portal.Configuration = nil // will be stored as {} and read back as empty map + + if err := repo.Create(portal); err != nil { + t.Fatalf("Create: %v", err) + } + got, err := repo.GetByUUID(portal.ID, orgUUID) + if err != nil { + t.Fatalf("GetByUUID: %v", err) + } + if got.Configuration == nil { + t.Fatal("Configuration is nil after round-trip; expected non-nil empty map") + } + if len(got.Configuration) != 0 { + t.Errorf("Configuration expected empty; got %v", got.Configuration) + } +} + +func TestAPIPortalRepo_Create_ConfigurationRoundTrip_Populated(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgUUID = "org-portal-cfg-full" + createTestAPIPortalOrg(t, db, orgUUID) + + repo := NewAPIPortalRepo(db) + portal := newTestAPIPortal("portal-cfg-full", orgUUID, "cfg-full") + portal.Configuration = map[string]interface{}{ + "stsTokenUrl": "https://sts.example.com/token", + "clientId": "abc", + "audience": []interface{}{"aud-1", "aud-2"}, + } + + if err := repo.Create(portal); err != nil { + t.Fatalf("Create: %v", err) + } + got, err := repo.GetByUUID(portal.ID, orgUUID) + if err != nil { + t.Fatalf("GetByUUID: %v", err) + } + if got.Configuration["stsTokenUrl"] != "https://sts.example.com/token" { + t.Errorf("stsTokenUrl round-trip failed; got %v", got.Configuration["stsTokenUrl"]) + } + if got.Configuration["clientId"] != "abc" { + t.Errorf("clientId round-trip failed; got %v", got.Configuration["clientId"]) + } + aud, ok := got.Configuration["audience"].([]interface{}) + if !ok || len(aud) != 2 || aud[0] != "aud-1" || aud[1] != "aud-2" { + t.Errorf("audience round-trip failed; got %v", got.Configuration["audience"]) + } +} + +func TestAPIPortalRepo_Create_DuplicateHandle(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgUUID = "org-portal-dup" + createTestAPIPortalOrg(t, db, orgUUID) + + repo := NewAPIPortalRepo(db) + if err := repo.Create(newTestAPIPortal("portal-dup-1", orgUUID, "dup")); err != nil { + t.Fatalf("first Create: %v", err) + } + err := repo.Create(newTestAPIPortal("portal-dup-2", orgUUID, "dup")) + if err == nil { + t.Fatal("expected duplicate handle to fail, got nil") + } + if !IsUniqueViolation(err) { + t.Errorf("expected unique-constraint violation, got %v", err) + } +} + +func TestAPIPortalRepo_Get_NotFound(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgUUID = "org-portal-nf" + createTestAPIPortalOrg(t, db, orgUUID) + + repo := NewAPIPortalRepo(db) + got, err := repo.GetByUUID("does-not-exist", orgUUID) + if err != nil { + t.Fatalf("GetByUUID: unexpected error: %v", err) + } + if got != nil { + t.Errorf("GetByUUID: expected nil for missing row, got %+v", got) + } + got2, err := repo.GetByHandleAndOrgID("no-such-handle", orgUUID) + if err != nil { + t.Fatalf("GetByHandleAndOrgID: unexpected error: %v", err) + } + if got2 != nil { + t.Errorf("GetByHandleAndOrgID: expected nil for missing row, got %+v", got2) + } +} + +func TestAPIPortalRepo_Get_CrossOrgIsolation(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgA = "org-portal-a" + const orgB = "org-portal-b" + createTestAPIPortalOrg(t, db, orgA) + createTestAPIPortalOrg(t, db, orgB) + + repo := NewAPIPortalRepo(db) + if err := repo.Create(newTestAPIPortal("portal-a", orgA, "shared-handle")); err != nil { + t.Fatalf("Create A: %v", err) + } + if err := repo.Create(newTestAPIPortal("portal-b", orgB, "shared-handle")); err != nil { + t.Fatalf("Create B (different org, same handle allowed): %v", err) + } + // A's portal-a must not be visible when querying org B. + got, err := repo.GetByUUID("portal-a", orgB) + if err != nil { + t.Fatalf("GetByUUID cross-org: %v", err) + } + if got != nil { + t.Errorf("cross-org leak: got %+v", got) + } +} + +func TestAPIPortalRepo_ListPaginated(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgUUID = "org-portal-list" + createTestAPIPortalOrg(t, db, orgUUID) + + repo := NewAPIPortalRepo(db) + // Insert 5 portals with staggered createdAt to make ordering deterministic. + for i, handle := range []string{"aa", "bb", "cc", "dd", "ee"} { + p := newTestAPIPortal("portal-"+handle, orgUUID, handle) + if err := repo.Create(p); err != nil { + t.Fatalf("Create %s: %v", handle, err) + } + // Nudge each row's created_at forward so DESC ordering is stable. + p.CreatedAt = time.Now().UTC().Add(time.Duration(i) * time.Millisecond) + if _, err := db.Exec(`UPDATE api_portals SET created_at = ? WHERE uuid = ?`, p.CreatedAt, p.ID); err != nil { + t.Fatalf("nudge created_at: %v", err) + } + } + + // Page 1: limit 2 → newest first ("ee", "dd"). + page1, err := repo.ListPaginated(orgUUID, nil, ListOptions{Limit: 2, Offset: 0}) + if err != nil { + t.Fatalf("ListPaginated page 1: %v", err) + } + if len(page1) != 2 { + t.Fatalf("page 1 size: want 2, got %d", len(page1)) + } + if page1[0].Handle != "ee" || page1[1].Handle != "dd" { + t.Errorf("page 1 order: got %s, %s", page1[0].Handle, page1[1].Handle) + } + + // Page 2: offset 2, limit 2 → "cc", "bb". + page2, err := repo.ListPaginated(orgUUID, nil, ListOptions{Limit: 2, Offset: 2}) + if err != nil { + t.Fatalf("ListPaginated page 2: %v", err) + } + if len(page2) != 2 || page2[0].Handle != "cc" || page2[1].Handle != "bb" { + t.Errorf("page 2: %+v", page2) + } + + // Count without filter. + total, err := repo.Count(orgUUID, nil, "") + if err != nil { + t.Fatalf("Count: %v", err) + } + if total != 5 { + t.Errorf("Count: want 5, got %d", total) + } +} + +func TestAPIPortalRepo_ListPaginated_WorkflowStatusFilter(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgUUID = "org-portal-status" + createTestAPIPortalOrg(t, db, orgUUID) + + repo := NewAPIPortalRepo(db) + // 2 pending, 1 active. + p1 := newTestAPIPortal("p1", orgUUID, "p1") + p1.WorkflowStatus = constants.APIPortalWorkflowStatusPending + if err := repo.Create(p1); err != nil { + t.Fatalf("Create p1: %v", err) + } + p2 := newTestAPIPortal("p2", orgUUID, "p2") + p2.WorkflowStatus = constants.APIPortalWorkflowStatusPending + if err := repo.Create(p2); err != nil { + t.Fatalf("Create p2: %v", err) + } + p3 := newTestAPIPortal("p3", orgUUID, "p3") + p3.WorkflowStatus = constants.APIPortalWorkflowStatusActive + if err := repo.Create(p3); err != nil { + t.Fatalf("Create p3: %v", err) + } + + active := constants.APIPortalWorkflowStatusActive + got, err := repo.ListPaginated(orgUUID, &active, ListOptions{Limit: 10, Offset: 0}) + if err != nil { + t.Fatalf("ListPaginated: %v", err) + } + if len(got) != 1 || got[0].Handle != "p3" { + t.Errorf("want 1 active portal (p3); got %+v", got) + } + + // Count with same filter must also reflect it (pagination-total consistency). + total, err := repo.Count(orgUUID, &active, "") + if err != nil { + t.Fatalf("Count: %v", err) + } + if total != 1 { + t.Errorf("filtered count: want 1, got %d", total) + } +} + +func TestAPIPortalRepo_ListPaginated_Search(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgUUID = "org-portal-search" + createTestAPIPortalOrg(t, db, orgUUID) + + repo := NewAPIPortalRepo(db) + for _, h := range []string{"acme-dev", "acme-prod", "other-portal"} { + if err := repo.Create(newTestAPIPortal("portal-"+h, orgUUID, h)); err != nil { + t.Fatalf("Create %s: %v", h, err) + } + } + got, err := repo.ListPaginated(orgUUID, nil, ListOptions{Limit: 10, Offset: 0, Search: "acme"}) + if err != nil { + t.Fatalf("ListPaginated: %v", err) + } + if len(got) != 2 { + t.Errorf("want 2 acme results, got %d: %+v", len(got), got) + } +} + +func TestAPIPortalRepo_Update(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgUUID = "org-portal-upd" + createTestAPIPortalOrg(t, db, orgUUID) + + repo := NewAPIPortalRepo(db) + portal := newTestAPIPortal("portal-upd", orgUUID, "upd") + if err := repo.Create(portal); err != nil { + t.Fatalf("Create: %v", err) + } + origCreatedAt := portal.CreatedAt + + // Mutate every whitelisted field + attempt to mutate an immutable one (handle). + // OrganizationID is left untouched because the UPDATE uses it in the WHERE + // clause for org isolation; cross-org attempts are covered by + // TestAPIPortalRepo_Update_CrossOrgIsolation. + portal.Name = "Renamed" + portal.Description = "new description" + portal.URL = "https://renamed.example.com" + portal.WorkflowStatus = constants.APIPortalWorkflowStatusActive + portal.AuthType = constants.APIPortalAuthTypeOAuth2 + portal.Configuration = map[string]interface{}{"stsTokenUrl": "https://sts/x"} + portal.UpdatedBy = "editor" + portal.Handle = "attempted-rename" // immutable — must NOT stick + + if err := repo.Update(portal); err != nil { + t.Fatalf("Update: %v", err) + } + + got, err := repo.GetByUUID("portal-upd", orgUUID) + if err != nil { + t.Fatalf("GetByUUID: %v", err) + } + if got == nil { + t.Fatal("row disappeared after Update") + } + if got.Name != "Renamed" || got.Description != "new description" || + got.URL != "https://renamed.example.com" || + got.WorkflowStatus != constants.APIPortalWorkflowStatusActive || + got.AuthType != constants.APIPortalAuthTypeOAuth2 || + got.UpdatedBy != "editor" { + t.Errorf("mutable fields not persisted; got %+v", got) + } + if got.Configuration["stsTokenUrl"] != "https://sts/x" { + t.Errorf("configuration not persisted; got %v", got.Configuration) + } + if got.Handle != "upd" { + t.Errorf("handle was mutated despite being immutable; want %q, got %q", "upd", got.Handle) + } + if !got.CreatedAt.Equal(origCreatedAt) { + t.Errorf("created_at was touched; before %v, after %v", origCreatedAt, got.CreatedAt) + } +} + +func TestAPIPortalRepo_Update_NotFound(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgUUID = "org-portal-upd-nf" + createTestAPIPortalOrg(t, db, orgUUID) + + repo := NewAPIPortalRepo(db) + err := repo.Update(newTestAPIPortal("ghost", orgUUID, "ghost")) + if err == nil { + t.Fatal("expected Update on missing row to error") + } + if !strings.Contains(err.Error(), "api portal not found") { + t.Errorf("want error containing %q, got %q", "api portal not found", err.Error()) + } +} + +func TestAPIPortalRepo_Update_CrossOrgIsolation(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgA = "org-portal-upd-a" + const orgB = "org-portal-upd-b" + createTestAPIPortalOrg(t, db, orgA) + createTestAPIPortalOrg(t, db, orgB) + + repo := NewAPIPortalRepo(db) + if err := repo.Create(newTestAPIPortal("portal-a", orgA, "iso")); err != nil { + t.Fatalf("Create: %v", err) + } + // Attempt to update A's portal claiming to be in org B — must be rejected as not-found. + portal := newTestAPIPortal("portal-a", orgB, "iso") + portal.Name = "hijack" + err := repo.Update(portal) + if err == nil { + t.Fatal("expected Update with wrong org to error as not-found") + } + if !strings.Contains(err.Error(), "api portal not found") { + t.Errorf("want error containing %q, got %q", "api portal not found", err.Error()) + } +} + +func TestAPIPortalRepo_Delete(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgUUID = "org-portal-del" + createTestAPIPortalOrg(t, db, orgUUID) + + repo := NewAPIPortalRepo(db) + portal := newTestAPIPortal("portal-del", orgUUID, "del") + if err := repo.Create(portal); err != nil { + t.Fatalf("Create: %v", err) + } + if err := repo.Delete(portal.ID, orgUUID); err != nil { + t.Fatalf("Delete: %v", err) + } + got, err := repo.GetByUUID(portal.ID, orgUUID) + if err != nil { + t.Fatalf("GetByUUID after Delete: %v", err) + } + if got != nil { + t.Errorf("row still present after Delete: %+v", got) + } +} + +func TestAPIPortalRepo_Delete_NotFound(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgUUID = "org-portal-del-nf" + createTestAPIPortalOrg(t, db, orgUUID) + + repo := NewAPIPortalRepo(db) + err := repo.Delete("ghost", orgUUID) + if err == nil { + t.Fatal("expected Delete on missing row to error") + } + if !strings.Contains(err.Error(), "api portal not found") { + t.Errorf("want error containing %q, got %q", "api portal not found", err.Error()) + } +} + +func TestAPIPortalRepo_Exists(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgUUID = "org-portal-exists" + createTestAPIPortalOrg(t, db, orgUUID) + + repo := NewAPIPortalRepo(db) + ok, err := repo.Exists("nope", orgUUID) + if err != nil { + t.Fatalf("Exists: %v", err) + } + if ok { + t.Error("Exists: expected false for missing row") + } + if err := repo.Create(newTestAPIPortal("portal-e", orgUUID, "here")); err != nil { + t.Fatalf("Create: %v", err) + } + ok, err = repo.Exists("here", orgUUID) + if err != nil { + t.Fatalf("Exists: %v", err) + } + if !ok { + t.Error("Exists: expected true for existing row") + } +} diff --git a/platform-api/internal/repository/interfaces.go b/platform-api/internal/repository/interfaces.go index 13354e371d..404babab5b 100644 --- a/platform-api/internal/repository/interfaces.go +++ b/platform-api/internal/repository/interfaces.go @@ -294,6 +294,25 @@ type LLMProxyRepository interface { EnsureGatewayAssociation(proxyUUID, gatewayUUID, orgUUID, createdBy, deployMetadata string, metadataProvided bool) (string, error) } +// APIPortalRepository defines the interface for API Portal persistence. +// See internal/model/api_portal.go for field semantics and internal/database/schema.postgres.sql +// (api_portals table) for storage layout. +type APIPortalRepository interface { + Create(portal *model.APIPortal) error + GetByUUID(portalID, orgUUID string) (*model.APIPortal, error) + GetByHandleAndOrgID(handle, orgUUID string) (*model.APIPortal, error) + // ListPaginated returns a page of API Portals scoped to the organization, + // optionally filtered by workflow_status. When workflowStatus is nil, all + // statuses are included. + ListPaginated(orgUUID string, workflowStatus *string, opts ListOptions) ([]*model.APIPortal, error) + // Count returns the total number of matching API Portals independent of + // pagination. workflowStatus follows the same rules as ListPaginated. + Count(orgUUID string, workflowStatus *string, search string) (int, error) + Update(portal *model.APIPortal) error + Delete(portalID, orgUUID string) error + Exists(handle, orgUUID string) (bool, error) +} + // MCPProxyRepository defines the interface for MCP proxy persistence type MCPProxyRepository interface { Create(p *model.MCPProxy) error From ebe5325fa155727812e28fee7ee3fb2e1f5ad523 Mon Sep 17 00:00:00 2001 From: dushaniw Date: Thu, 13 Aug 2026 12:37:11 +0530 Subject: [PATCH 05/25] add service and rest api implementation. --- platform-api/api/generated.go | 403 ++++++++++++++- platform-api/internal/apperror/catalog.go | 6 + platform-api/internal/apperror/codes.go | 6 + platform-api/internal/handler/api_portal.go | 289 +++++++++++ platform-api/internal/server/server.go | 4 + platform-api/internal/service/api_portal.go | 305 ++++++++++++ .../internal/service/api_portal_test.go | 465 ++++++++++++++++++ platform-api/resources/openapi.yaml | 403 +++++++++++++++ .../resources/role-to-scope-mapping.yaml | 4 + 9 files changed, 1860 insertions(+), 25 deletions(-) create mode 100644 platform-api/internal/handler/api_portal.go create mode 100644 platform-api/internal/service/api_portal.go create mode 100644 platform-api/internal/service/api_portal_test.go diff --git a/platform-api/api/generated.go b/platform-api/api/generated.go index e5050eb044..ee1c404f16 100644 --- a/platform-api/api/generated.go +++ b/platform-api/api/generated.go @@ -29,6 +29,32 @@ const ( APIKeySecurityInQuery APIKeySecurityIn = "query" ) +// Defines values for ApiPortalListItemAuthType. +const ( + ApiPortalListItemAuthTypeLocal ApiPortalListItemAuthType = "local" + ApiPortalListItemAuthTypeOauth2 ApiPortalListItemAuthType = "oauth2" +) + +// Defines values for ApiPortalListItemWorkflowStatus. +const ( + ApiPortalListItemWorkflowStatusActive ApiPortalListItemWorkflowStatus = "active" + ApiPortalListItemWorkflowStatusFailed ApiPortalListItemWorkflowStatus = "failed" + ApiPortalListItemWorkflowStatusPending ApiPortalListItemWorkflowStatus = "pending" +) + +// Defines values for ApiPortalResponseAuthType. +const ( + ApiPortalResponseAuthTypeLocal ApiPortalResponseAuthType = "local" + ApiPortalResponseAuthTypeOauth2 ApiPortalResponseAuthType = "oauth2" +) + +// Defines values for ApiPortalResponseWorkflowStatus. +const ( + ApiPortalResponseWorkflowStatusActive ApiPortalResponseWorkflowStatus = "active" + ApiPortalResponseWorkflowStatusFailed ApiPortalResponseWorkflowStatus = "failed" + ApiPortalResponseWorkflowStatusPending ApiPortalResponseWorkflowStatus = "pending" +) + // Defines values for ApplicationAssociationSelectorKind. const ( ApplicationAssociationSelectorKindLlmProvider ApplicationAssociationSelectorKind = "LlmProvider" @@ -51,6 +77,12 @@ const ( CreateAPIKeyResponseStatusSuccess CreateAPIKeyResponseStatus = "success" ) +// Defines values for CreateApiPortalRequestAuthType. +const ( + CreateApiPortalRequestAuthTypeLocal CreateApiPortalRequestAuthType = "local" + CreateApiPortalRequestAuthTypeOauth2 CreateApiPortalRequestAuthType = "oauth2" +) + // Defines values for CreateGatewayRequestFunctionalityType. const ( CreateGatewayRequestFunctionalityTypeAi CreateGatewayRequestFunctionalityType = "ai" @@ -159,9 +191,9 @@ const ( // Defines values for MCPProxyListItemStatus. const ( - Deployed MCPProxyListItemStatus = "deployed" - Failed MCPProxyListItemStatus = "failed" - Pending MCPProxyListItemStatus = "pending" + MCPProxyListItemStatusDeployed MCPProxyListItemStatus = "deployed" + MCPProxyListItemStatusFailed MCPProxyListItemStatus = "failed" + MCPProxyListItemStatusPending MCPProxyListItemStatus = "pending" ) // Defines values for OperationPolicyPathMethods. @@ -313,6 +345,19 @@ const ( UpdateAPIKeyResponseStatusSuccess UpdateAPIKeyResponseStatus = "success" ) +// Defines values for UpdateApiPortalRequestAuthType. +const ( + UpdateApiPortalRequestAuthTypeLocal UpdateApiPortalRequestAuthType = "local" + UpdateApiPortalRequestAuthTypeOauth2 UpdateApiPortalRequestAuthType = "oauth2" +) + +// Defines values for UpdateApiPortalRequestWorkflowStatus. +const ( + UpdateApiPortalRequestWorkflowStatusActive UpdateApiPortalRequestWorkflowStatus = "active" + UpdateApiPortalRequestWorkflowStatusFailed UpdateApiPortalRequestWorkflowStatus = "failed" + UpdateApiPortalRequestWorkflowStatusPending UpdateApiPortalRequestWorkflowStatus = "pending" +) + // Defines values for UpstreamAuthType. const ( ApiKey UpstreamAuthType = "api-key" @@ -331,9 +376,16 @@ const ( // Defines values for UserAPIKeyItemStatus. const ( - Active UserAPIKeyItemStatus = "active" - Expired UserAPIKeyItemStatus = "expired" - Revoked UserAPIKeyItemStatus = "revoked" + UserAPIKeyItemStatusActive UserAPIKeyItemStatus = "active" + UserAPIKeyItemStatusExpired UserAPIKeyItemStatus = "expired" + UserAPIKeyItemStatusRevoked UserAPIKeyItemStatus = "revoked" +) + +// Defines values for ApiPortalWorkflowStatusQ. +const ( + ApiPortalWorkflowStatusQActive ApiPortalWorkflowStatusQ = "active" + ApiPortalWorkflowStatusQFailed ApiPortalWorkflowStatusQ = "failed" + ApiPortalWorkflowStatusQPending ApiPortalWorkflowStatusQ = "pending" ) // Defines values for DeploymentStatusQ. @@ -358,6 +410,25 @@ const ( SortOrderQDesc SortOrderQ = "desc" ) +// Defines values for ListApiPortalsParamsSortBy. +const ( + ListApiPortalsParamsSortByCreatedAt ListApiPortalsParamsSortBy = "createdAt" + ListApiPortalsParamsSortByName ListApiPortalsParamsSortBy = "name" +) + +// Defines values for ListApiPortalsParamsSortOrder. +const ( + ListApiPortalsParamsSortOrderAsc ListApiPortalsParamsSortOrder = "asc" + ListApiPortalsParamsSortOrderDesc ListApiPortalsParamsSortOrder = "desc" +) + +// Defines values for ListApiPortalsParamsWorkflowStatus. +const ( + Active ListApiPortalsParamsWorkflowStatus = "active" + Failed ListApiPortalsParamsWorkflowStatus = "failed" + Pending ListApiPortalsParamsWorkflowStatus = "pending" +) + // Defines values for ListApplicationsParamsSortBy. const ( ListApplicationsParamsSortByCreatedAt ListApplicationsParamsSortBy = "createdAt" @@ -433,14 +504,14 @@ const ( // Defines values for ListRESTAPIsParamsSortBy. const ( - CreatedAt ListRESTAPIsParamsSortBy = "createdAt" - Name ListRESTAPIsParamsSortBy = "name" + ListRESTAPIsParamsSortByCreatedAt ListRESTAPIsParamsSortBy = "createdAt" + ListRESTAPIsParamsSortByName ListRESTAPIsParamsSortBy = "name" ) // Defines values for ListRESTAPIsParamsSortOrder. const ( - Asc ListRESTAPIsParamsSortOrder = "asc" - Desc ListRESTAPIsParamsSortOrder = "desc" + ListRESTAPIsParamsSortOrderAsc ListRESTAPIsParamsSortOrder = "asc" + ListRESTAPIsParamsSortOrderDesc ListRESTAPIsParamsSortOrder = "desc" ) // Defines values for GetDeploymentsParamsStatus. @@ -546,6 +617,72 @@ type AddGatewayToRESTAPIRequest struct { GatewayId string `binding:"required" json:"gatewayId" yaml:"gatewayId"` } +// ApiPortalConfig Configuration for how Platform API authenticates to the portal's admin +// API. Shape depends on `authType`; treated as an opaque object at the +// wire level. +type ApiPortalConfig map[string]interface{} + +// ApiPortalListItem Lightweight projection returned in collection responses (excludes the `config` blob). +type ApiPortalListItem struct { + AuthType ApiPortalListItemAuthType `binding:"required" json:"authType" yaml:"authType"` + CreatedAt time.Time `binding:"required" json:"createdAt" yaml:"createdAt"` + Description *string `json:"description" yaml:"description"` + Handle string `binding:"required" json:"handle" yaml:"handle"` + Id string `binding:"required" json:"id" yaml:"id"` + Name string `binding:"required" json:"name" yaml:"name"` + Url *string `json:"url" yaml:"url"` + WorkflowStatus ApiPortalListItemWorkflowStatus `binding:"required" json:"workflowStatus" yaml:"workflowStatus"` +} + +// ApiPortalListItemAuthType defines model for ApiPortalListItem.AuthType. +type ApiPortalListItemAuthType string + +// ApiPortalListItemWorkflowStatus defines model for ApiPortalListItem.WorkflowStatus. +type ApiPortalListItemWorkflowStatus string + +// ApiPortalListResponse defines model for ApiPortalListResponse. +type ApiPortalListResponse struct { + // Count Number of items in the current response page. + Count int `binding:"required" json:"count" yaml:"count"` + List []ApiPortalListItem `binding:"required" json:"list" yaml:"list"` + Pagination Pagination `json:"pagination" yaml:"pagination"` +} + +// ApiPortalResponse defines model for ApiPortalResponse. +type ApiPortalResponse struct { + // AuthType Determines how Platform API authenticates to the portal's admin API and selects the shape of the `config` object. + AuthType ApiPortalResponseAuthType `binding:"required" json:"authType" yaml:"authType"` + + // Config Configuration for how Platform API authenticates to the portal's admin + // API. Shape depends on `authType`; treated as an opaque object at the + // wire level. + Config *ApiPortalConfig `json:"config,omitempty" yaml:"config,omitempty"` + CreatedAt *time.Time `binding:"required" json:"createdAt,omitempty" yaml:"createdAt,omitempty"` + Description *string `json:"description" yaml:"description"` + + // Handle URL-friendly slug. Immutable after creation. Equal to `id`. + Handle *string `binding:"required" json:"handle,omitempty" yaml:"handle,omitempty"` + + // Id Handle (URL-friendly slug) of the API Portal — primary identifier. + Id *string `binding:"required" json:"id,omitempty" yaml:"id,omitempty"` + + // Name Display name. + Name string `binding:"required" json:"name" yaml:"name"` + UpdatedAt *time.Time `binding:"required" json:"updatedAt,omitempty" yaml:"updatedAt,omitempty"` + + // Url Public URL of the API Portal. May be null while the portal is being provisioned; populated once the instance is reachable. + Url *string `json:"url" yaml:"url"` + + // WorkflowStatus Lifecycle state. `pending` — portal is being provisioned or activated. `active` — portal is reachable and functional. `failed` — provisioning or a subsequent health check failed. + WorkflowStatus ApiPortalResponseWorkflowStatus `binding:"required" json:"workflowStatus" yaml:"workflowStatus"` +} + +// ApiPortalResponseAuthType Determines how Platform API authenticates to the portal's admin API and selects the shape of the `config` object. +type ApiPortalResponseAuthType string + +// ApiPortalResponseWorkflowStatus Lifecycle state. `pending` — portal is being provisioned or activated. `active` — portal is reachable and functional. `failed` — provisioning or a subsequent health check failed. +type ApiPortalResponseWorkflowStatus string + // Application defines model for Application. type Application struct { CreatedAt *time.Time `json:"createdAt,omitempty" yaml:"createdAt,omitempty"` @@ -718,6 +855,27 @@ type CreateAPIKeyResponse struct { // CreateAPIKeyResponseStatus Status of the operation type CreateAPIKeyResponseStatus string +// CreateApiPortalRequest defines model for CreateApiPortalRequest. +type CreateApiPortalRequest struct { + AuthType CreateApiPortalRequestAuthType `binding:"required" json:"authType" yaml:"authType"` + + // Config Configuration for how Platform API authenticates to the portal's admin + // API. Shape depends on `authType`; treated as an opaque object at the + // wire level. + Config *ApiPortalConfig `json:"config,omitempty" yaml:"config,omitempty"` + Description *string `json:"description" yaml:"description"` + + // Handle URL-friendly slug. Must be unique within the org. Immutable after creation. + Handle string `binding:"required" json:"handle" yaml:"handle"` + Name string `binding:"required" json:"name" yaml:"name"` + + // Url Public URL of an existing API Portal to register. Omit to have a new portal provisioned; the URL will be populated once the instance is reachable. + Url *string `json:"url" yaml:"url"` +} + +// CreateApiPortalRequestAuthType defines model for CreateApiPortalRequest.AuthType. +type CreateApiPortalRequestAuthType string + // CreateApplicationRequest Request body for creating an application. type CreateApplicationRequest struct { // Description Description of the application @@ -1312,7 +1470,7 @@ type LLMProvider struct { // AssociatedGateways Optional list of gateways this LLM provider can be deployed to, along with per-gateway configuration overrides. This field is optional; omitting it does not change existing behaviour. AssociatedGateways *[]AssociatedGateway `json:"associatedGateways,omitempty" yaml:"associatedGateways,omitempty"` - // Context Base path for all routes exposed by this proxy. Must start with / and carry no trailing slash; the single exception is the root path "/", which is the default. + // Context Base path for all routes exposed by this provider. Must start with / and carry no trailing slash; the single exception is the root path "/", which is the default. Context *string `json:"context,omitempty" yaml:"context,omitempty"` // CreatedAt Timestamp when the resource was created @@ -1818,22 +1976,31 @@ type MCPProxyListResponse struct { Pagination Pagination `json:"pagination" yaml:"pagination"` } -// MCPServerInfoFetchRequest defines model for MCPServerInfoFetchRequest. +// MCPServerInfoFetchRequest Target MCP server to introspect, and the credentials to introspect it with. At least +// one of `url`/`proxyId` must be provided type MCPServerInfoFetchRequest struct { // Auth Authentication configuration for upstream endpoints Auth *UpstreamAuth `json:"auth,omitempty" yaml:"auth,omitempty"` - // ProxyId MCP proxy handle (identifier) for refresh operations. When provided, - // the server fetches URL and auth from the stored proxy configuration. - // Auth override is not allowed in refetch mode. + // ProxyId MCP proxy handle (identifier) for refresh operations. The stored credentials of + // this proxy are used for the fetch, and its stored upstream URL too unless `url` + // overrides it. Required unless `url` is given. ProxyId *string `json:"proxyId,omitempty" yaml:"proxyId,omitempty"` - // Url Endpoint URL of the MCP server to fetch information from. - // Required when proxyId is not provided. When proxyId is provided, - // the URL from the stored proxy configuration is used. - Url *string `json:"url,omitempty" yaml:"url,omitempty"` + // Url Endpoint URL of the MCP server to fetch information from. Required unless + // `proxyId` is given. When sent together with `proxyId` it overrides that proxy's + // stored upstream URL, while the proxy's stored credentials are still used — this + // validates an unsaved endpoint edit without re-sending a write-only secret. + Url *string `json:"url,omitempty" yaml:"url,omitempty"` + union json.RawMessage } +// MCPServerInfoFetchRequest0 defines model for . +type MCPServerInfoFetchRequest0 = interface{} + +// MCPServerInfoFetchRequest1 defines model for . +type MCPServerInfoFetchRequest1 = interface{} + // MCPServerInfoFetchResponse defines model for MCPServerInfoFetchResponse. type MCPServerInfoFetchResponse struct { Prompts *[]map[string]interface{} `json:"prompts,omitempty" yaml:"prompts,omitempty"` @@ -2256,7 +2423,7 @@ type SecretCreateRequest struct { Type *SecretCreateRequestType `json:"type,omitempty" yaml:"type,omitempty"` // Value Plaintext secret value — encrypted at rest, never returned in any response - Value string `binding:"required" json:"value" yaml:"value"` + Value *string `binding:"required" json:"value,omitempty" yaml:"value,omitempty"` } // SecretCreateRequestType defines model for SecretCreateRequest.Type. @@ -2324,7 +2491,7 @@ type SecretUpdateRequest struct { Id *string `json:"id,omitempty" yaml:"id,omitempty"` // Value New plaintext secret value — re-encrypted at rest - Value string `binding:"required" json:"value" yaml:"value"` + Value *string `binding:"required" json:"value,omitempty" yaml:"value,omitempty"` } // SecurityConfig Defines security mechanisms (API key, OAuth2) applicable to the API @@ -2540,6 +2707,26 @@ type UpdateAPIKeyResponse struct { // UpdateAPIKeyResponseStatus Status of the operation type UpdateAPIKeyResponseStatus string +// UpdateApiPortalRequest All fields optional. Only mutable fields are accepted — see field permissions in the design doc. +type UpdateApiPortalRequest struct { + AuthType *UpdateApiPortalRequestAuthType `json:"authType,omitempty" yaml:"authType,omitempty"` + + // Config Configuration for how Platform API authenticates to the portal's admin + // API. Shape depends on `authType`; treated as an opaque object at the + // wire level. + Config *ApiPortalConfig `json:"config,omitempty" yaml:"config,omitempty"` + Description *string `json:"description" yaml:"description"` + Name *string `json:"name,omitempty" yaml:"name,omitempty"` + Url *string `json:"url" yaml:"url"` + WorkflowStatus *UpdateApiPortalRequestWorkflowStatus `json:"workflowStatus,omitempty" yaml:"workflowStatus,omitempty"` +} + +// UpdateApiPortalRequestAuthType defines model for UpdateApiPortalRequest.AuthType. +type UpdateApiPortalRequestAuthType string + +// UpdateApiPortalRequestWorkflowStatus defines model for UpdateApiPortalRequest.WorkflowStatus. +type UpdateApiPortalRequestWorkflowStatus string + // Upstream Upstream backend configuration with main and sandbox endpoints type Upstream struct { // Main Upstream endpoint configuration. Provide exactly one of `url` (a direct backend URL) or @@ -2644,6 +2831,12 @@ type UserAPIKeyListResponse struct { // ApiId defines model for apiId. type ApiId = string +// ApiPortalId defines model for apiPortalId. +type ApiPortalId = string + +// ApiPortalWorkflowStatusQ defines model for apiPortalWorkflowStatus-Q. +type ApiPortalWorkflowStatusQ string + // AppId defines model for appId. type AppId = string @@ -2719,6 +2912,36 @@ type ServiceUnavailable = Error // Unauthorized The single error shape returned by every failed request across the API. type Unauthorized = Error +// ListApiPortalsParams defines parameters for ListApiPortals. +type ListApiPortalsParams struct { + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` + + // SortBy Field to sort the collection by. An unrecognized value falls back to the default sort (createdAt). + SortBy *ListApiPortalsParamsSortBy `form:"sortBy,omitempty" json:"sortBy,omitempty" yaml:"sortBy,omitempty"` + + // SortOrder Sort direction applied to `sortBy`. + SortOrder *ListApiPortalsParamsSortOrder `form:"sortOrder,omitempty" json:"sortOrder,omitempty" yaml:"sortOrder,omitempty"` + + // Query Case-insensitive substring filter matched against the resource id (handle). + Query *QueryQ `form:"query,omitempty" json:"query,omitempty" yaml:"query,omitempty"` + + // WorkflowStatus Filter API Portals by lifecycle state. + WorkflowStatus *ListApiPortalsParamsWorkflowStatus `form:"workflowStatus,omitempty" json:"workflowStatus,omitempty" yaml:"workflowStatus,omitempty"` +} + +// ListApiPortalsParamsSortBy defines parameters for ListApiPortals. +type ListApiPortalsParamsSortBy string + +// ListApiPortalsParamsSortOrder defines parameters for ListApiPortals. +type ListApiPortalsParamsSortOrder string + +// ListApiPortalsParamsWorkflowStatus defines parameters for ListApiPortals. +type ListApiPortalsParamsWorkflowStatus string + // ListApplicationsParams defines parameters for ListApplications. type ListApplicationsParams struct { // ProjectId **Project ID** consisting of the **handle** (unique slug identifier) of the Project whose resources should be returned. @@ -2883,7 +3106,7 @@ type ListLLMProviderAPIKeysParams struct { // GetLLMProviderDeploymentsParams defines parameters for GetLLMProviderDeployments. type GetLLMProviderDeploymentsParams struct { - // GatewayId **Gateway ID** (handle — unique slug identifier) of the Gateway to filter deployments by. + // GatewayId **Gateway ID** consisting of the **handle** (unique slug identifier) of the Gateway to filter status by. GatewayId *GatewayIdQ `form:"gatewayId,omitempty" json:"gatewayId,omitempty" yaml:"gatewayId,omitempty"` // Status Filter deployments by status (DEPLOYED, UNDEPLOYED, DEPLOYING, UNDEPLOYING, FAILED, or ARCHIVED) @@ -2943,7 +3166,7 @@ type ListLLMProxyAPIKeysParams struct { // GetLLMProxyDeploymentsParams defines parameters for GetLLMProxyDeployments. type GetLLMProxyDeploymentsParams struct { - // GatewayId **Gateway ID** (handle — unique slug identifier) of the Gateway to filter deployments by. + // GatewayId **Gateway ID** consisting of the **handle** (unique slug identifier) of the Gateway to filter status by. GatewayId *GatewayIdQ `form:"gatewayId,omitempty" json:"gatewayId,omitempty" yaml:"gatewayId,omitempty"` // Status Filter deployments by status (DEPLOYED, UNDEPLOYED, DEPLOYING, UNDEPLOYING, FAILED, or ARCHIVED) @@ -2985,7 +3208,7 @@ type ListMCPProxiesParams struct { // GetMCPProxyDeploymentsParams defines parameters for GetMCPProxyDeployments. type GetMCPProxyDeploymentsParams struct { - // GatewayId **Gateway ID** (handle — unique slug identifier) of the Gateway to filter deployments by. + // GatewayId **Gateway ID** consisting of the **handle** (unique slug identifier) of the Gateway to filter status by. GatewayId *GatewayIdQ `form:"gatewayId,omitempty" json:"gatewayId,omitempty" yaml:"gatewayId,omitempty"` // Status Filter deployments by status (DEPLOYED, UNDEPLOYED, DEPLOYING, UNDEPLOYING, FAILED, or ARCHIVED) @@ -3091,7 +3314,7 @@ type ListRESTAPIsParamsSortOrder string // GetDeploymentsParams defines parameters for GetDeployments. type GetDeploymentsParams struct { - // GatewayId **Gateway ID** (handle — unique slug identifier) of the Gateway to filter deployments by. + // GatewayId **Gateway ID** consisting of the **handle** (unique slug identifier) of the Gateway to filter status by. GatewayId *GatewayIdQ `form:"gatewayId,omitempty" json:"gatewayId,omitempty" yaml:"gatewayId,omitempty"` // Status Filter deployments by status (DEPLOYED, UNDEPLOYED, DEPLOYING, UNDEPLOYING, FAILED, or ARCHIVED) @@ -3188,6 +3411,12 @@ type UpdateSubscriptionParams struct { SubscriberId string `form:"subscriberId" json:"subscriberId" yaml:"subscriberId"` } +// CreateApiPortalJSONRequestBody defines body for CreateApiPortal for application/json ContentType. +type CreateApiPortalJSONRequestBody = CreateApiPortalRequest + +// UpdateApiPortalJSONRequestBody defines body for UpdateApiPortal for application/json ContentType. +type UpdateApiPortalJSONRequestBody = UpdateApiPortalRequest + // CreateApplicationJSONRequestBody defines body for CreateApplication for application/json ContentType. type CreateApplicationJSONRequestBody = CreateApplicationRequest @@ -3299,6 +3528,130 @@ type CreateSubscriptionJSONRequestBody = CreateSubscriptionRequest // UpdateSubscriptionJSONRequestBody defines body for UpdateSubscription for application/json ContentType. type UpdateSubscriptionJSONRequestBody = Subscription +// AsMCPServerInfoFetchRequest0 returns the union data inside the MCPServerInfoFetchRequest as a MCPServerInfoFetchRequest0 +func (t MCPServerInfoFetchRequest) AsMCPServerInfoFetchRequest0() (MCPServerInfoFetchRequest0, error) { + var body MCPServerInfoFetchRequest0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromMCPServerInfoFetchRequest0 overwrites any union data inside the MCPServerInfoFetchRequest as the provided MCPServerInfoFetchRequest0 +func (t *MCPServerInfoFetchRequest) FromMCPServerInfoFetchRequest0(v MCPServerInfoFetchRequest0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeMCPServerInfoFetchRequest0 performs a merge with any union data inside the MCPServerInfoFetchRequest, using the provided MCPServerInfoFetchRequest0 +func (t *MCPServerInfoFetchRequest) MergeMCPServerInfoFetchRequest0(v MCPServerInfoFetchRequest0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsMCPServerInfoFetchRequest1 returns the union data inside the MCPServerInfoFetchRequest as a MCPServerInfoFetchRequest1 +func (t MCPServerInfoFetchRequest) AsMCPServerInfoFetchRequest1() (MCPServerInfoFetchRequest1, error) { + var body MCPServerInfoFetchRequest1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromMCPServerInfoFetchRequest1 overwrites any union data inside the MCPServerInfoFetchRequest as the provided MCPServerInfoFetchRequest1 +func (t *MCPServerInfoFetchRequest) FromMCPServerInfoFetchRequest1(v MCPServerInfoFetchRequest1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeMCPServerInfoFetchRequest1 performs a merge with any union data inside the MCPServerInfoFetchRequest, using the provided MCPServerInfoFetchRequest1 +func (t *MCPServerInfoFetchRequest) MergeMCPServerInfoFetchRequest1(v MCPServerInfoFetchRequest1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t MCPServerInfoFetchRequest) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + if err != nil { + return nil, err + } + object := make(map[string]json.RawMessage) + if t.union != nil { + err = json.Unmarshal(b, &object) + if err != nil { + return nil, err + } + } + + if t.Auth != nil { + object["auth"], err = json.Marshal(t.Auth) + if err != nil { + return nil, fmt.Errorf("error marshaling 'auth': %w", err) + } + } + + if t.ProxyId != nil { + object["proxyId"], err = json.Marshal(t.ProxyId) + if err != nil { + return nil, fmt.Errorf("error marshaling 'proxyId': %w", err) + } + } + + if t.Url != nil { + object["url"], err = json.Marshal(t.Url) + if err != nil { + return nil, fmt.Errorf("error marshaling 'url': %w", err) + } + } + b, err = json.Marshal(object) + return b, err +} + +func (t *MCPServerInfoFetchRequest) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + if err != nil { + return err + } + object := make(map[string]json.RawMessage) + err = json.Unmarshal(b, &object) + if err != nil { + return err + } + + if raw, found := object["auth"]; found { + err = json.Unmarshal(raw, &t.Auth) + if err != nil { + return fmt.Errorf("error reading 'auth': %w", err) + } + } + + if raw, found := object["proxyId"]; found { + err = json.Unmarshal(raw, &t.ProxyId) + if err != nil { + return fmt.Errorf("error reading 'proxyId': %w", err) + } + } + + if raw, found := object["url"]; found { + err = json.Unmarshal(raw, &t.Url) + if err != nil { + return fmt.Errorf("error reading 'url': %w", err) + } + } + + return err +} + // AsRateLimitingScopeConfig0 returns the union data inside the RateLimitingScopeConfig as a RateLimitingScopeConfig0 func (t RateLimitingScopeConfig) AsRateLimitingScopeConfig0() (RateLimitingScopeConfig0, error) { var body RateLimitingScopeConfig0 diff --git a/platform-api/internal/apperror/catalog.go b/platform-api/internal/apperror/catalog.go index 9f8af379da..4313665772 100644 --- a/platform-api/internal/apperror/catalog.go +++ b/platform-api/internal/apperror/catalog.go @@ -148,6 +148,12 @@ var ( ApplicationExists = def(CodeApplicationExists, http.StatusConflict, "An application with this name already exists.") ) +// API Portal entries. +var ( + APIPortalNotFound = def(CodeAPIPortalNotFound, http.StatusNotFound, "The specified API Portal could not be found.") + APIPortalExists = def(CodeAPIPortalExists, http.StatusConflict, "An API Portal with this handle already exists in the organization.") +) + // Subscription entries. var ( SubscriptionNotFound = def(CodeSubscriptionNotFound, http.StatusNotFound, "The specified subscription could not be found.") diff --git a/platform-api/internal/apperror/codes.go b/platform-api/internal/apperror/codes.go index 2ca8714885..02f2ed0352 100644 --- a/platform-api/internal/apperror/codes.go +++ b/platform-api/internal/apperror/codes.go @@ -130,6 +130,12 @@ const ( CodeApplicationExists = "APPLICATION_EXISTS" ) +// API Portal domain codes. +const ( + CodeAPIPortalNotFound = "API_PORTAL_NOT_FOUND" + CodeAPIPortalExists = "API_PORTAL_EXISTS" +) + // Subscription domain codes. const ( CodeSubscriptionNotFound = "SUBSCRIPTION_NOT_FOUND" diff --git a/platform-api/internal/handler/api_portal.go b/platform-api/internal/handler/api_portal.go new file mode 100644 index 0000000000..7acaf25950 --- /dev/null +++ b/platform-api/internal/handler/api_portal.go @@ -0,0 +1,289 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed 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 handler + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "strings" + + "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/middleware" + "github.com/wso2/api-platform/platform-api/internal/model" + "github.com/wso2/api-platform/platform-api/internal/router" + "github.com/wso2/api-platform/platform-api/internal/service" + + "github.com/wso2/go-httpkit/httputil" +) + +// APIPortalHandler exposes /api-portals CRUD. The generated OpenAPI types +// (api.CreateApiPortalRequest / api.ApiPortalResponse / …) are the wire contract; +// this file only translates between them and the service layer. +type APIPortalHandler struct { + svc *service.APIPortalService + identity *service.IdentityService + slogger *slog.Logger +} + +// NewAPIPortalHandler constructs an APIPortalHandler. +func NewAPIPortalHandler(svc *service.APIPortalService, identity *service.IdentityService, slogger *slog.Logger) *APIPortalHandler { + return &APIPortalHandler{svc: svc, identity: identity, slogger: slogger} +} + +// CreateAPIPortal — POST /api-portals +func (h *APIPortalHandler) CreateAPIPortal(w http.ResponseWriter, r *http.Request) error { + orgID, ok := middleware.GetOrganizationFromRequest(r) + if !ok { + return apperror.Unauthorized.New().WithLogMessage("organization claim not found in token") + } + + var req api.CreateApiPortalRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return apperror.NewValidation(err) + } + + createdBy, err := resolveActorErr(r, h.identity, "create api portal") + if err != nil { + return err + } + + svcReq := &service.CreateAPIPortalRequest{ + Handle: strings.TrimSpace(req.Handle), + Name: strings.TrimSpace(req.Name), + Description: deref(req.Description), + URL: deref(req.Url), + AuthType: string(req.AuthType), + Configuration: derefConfig(req.Config), + } + portal, err := h.svc.CreateAPIPortal(svcReq, orgID, createdBy) + if err != nil { + return serviceError(err, fmt.Sprintf("failed to create api portal %q for org %s by user %s", svcReq.Handle, orgID, createdBy)) + } + + setLocation(w, "api-portals", portal.Handle) + httputil.WriteJSON(w, http.StatusCreated, modelToAPIPortalResponse(portal)) + return nil +} + +// GetAPIPortal — GET /api-portals/{apiPortalId} +func (h *APIPortalHandler) GetAPIPortal(w http.ResponseWriter, r *http.Request) error { + orgID, ok := middleware.GetOrganizationFromRequest(r) + if !ok { + return apperror.Unauthorized.New().WithLogMessage("organization claim not found in token") + } + + handle := strings.TrimSpace(r.PathValue("apiPortalId")) + if handle == "" { + return apperror.ValidationFailed.New("API Portal ID is required") + } + + portal, err := h.svc.GetAPIPortal(handle, orgID) + if err != nil { + return serviceError(err, fmt.Sprintf("failed to get api portal %q in org %s", handle, orgID)) + } + httputil.WriteJSON(w, http.StatusOK, modelToAPIPortalResponse(portal)) + return nil +} + +// ListAPIPortals — GET /api-portals +func (h *APIPortalHandler) ListAPIPortals(w http.ResponseWriter, r *http.Request) error { + orgID, ok := middleware.GetOrganizationFromRequest(r) + if !ok { + return apperror.Unauthorized.New().WithLogMessage("organization claim not found in token") + } + + opts := service.APIPortalListOptions{ListOptions: parseListOptions(r)} + if ws := strings.TrimSpace(r.URL.Query().Get("workflowStatus")); ws != "" { + opts.WorkflowStatus = &ws + } + + resp, err := h.svc.ListAPIPortals(orgID, opts) + if err != nil { + return serviceError(err, fmt.Sprintf("failed to list api portals for org %s", orgID)) + } + httputil.WriteJSON(w, http.StatusOK, apiPortalListResponse(resp)) + return nil +} + +// UpdateAPIPortal — PUT /api-portals/{apiPortalId} +func (h *APIPortalHandler) UpdateAPIPortal(w http.ResponseWriter, r *http.Request) error { + orgID, ok := middleware.GetOrganizationFromRequest(r) + if !ok { + return apperror.Unauthorized.New().WithLogMessage("organization claim not found in token") + } + + handle := strings.TrimSpace(r.PathValue("apiPortalId")) + if handle == "" { + return apperror.ValidationFailed.New("API Portal ID is required") + } + + var req api.UpdateApiPortalRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return apperror.NewValidation(err) + } + + updatedBy, err := resolveActorErr(r, h.identity, "update api portal") + if err != nil { + return err + } + + svcReq := &service.UpdateAPIPortalRequest{ + Name: req.Name, + Description: req.Description, + URL: req.Url, + Configuration: derefConfig(req.Config), + } + if req.WorkflowStatus != nil { + v := string(*req.WorkflowStatus) + svcReq.WorkflowStatus = &v + } + if req.AuthType != nil { + v := string(*req.AuthType) + svcReq.AuthType = &v + } + + portal, err := h.svc.UpdateAPIPortal(handle, svcReq, orgID, updatedBy) + if err != nil { + return serviceError(err, fmt.Sprintf("failed to update api portal %q in org %s by user %s", handle, orgID, updatedBy)) + } + httputil.WriteJSON(w, http.StatusOK, modelToAPIPortalResponse(portal)) + return nil +} + +// DeleteAPIPortal — DELETE /api-portals/{apiPortalId} +func (h *APIPortalHandler) DeleteAPIPortal(w http.ResponseWriter, r *http.Request) error { + orgID, ok := middleware.GetOrganizationFromRequest(r) + if !ok { + return apperror.Unauthorized.New().WithLogMessage("organization claim not found in token") + } + + handle := strings.TrimSpace(r.PathValue("apiPortalId")) + if handle == "" { + return apperror.ValidationFailed.New("API Portal ID is required") + } + + actor, err := resolveActorErr(r, h.identity, "delete api portal") + if err != nil { + return err + } + + if err := h.svc.DeleteAPIPortal(handle, orgID, actor); err != nil { + return serviceError(err, fmt.Sprintf("failed to delete api portal %q in org %s by user %s", handle, orgID, actor)) + } + w.WriteHeader(http.StatusNoContent) + return nil +} + +// RegisterRoutes wires all /api-portals routes onto the shared mux. +func (h *APIPortalHandler) RegisterRoutes(mux router.Router) { + base := constants.APIBasePath + "/api-portals" + mux.HandleFunc("POST "+base, middleware.MapErrors(h.slogger, h.CreateAPIPortal)) + mux.HandleFunc("GET "+base, middleware.MapErrors(h.slogger, h.ListAPIPortals)) + mux.HandleFunc("GET "+base+"/{apiPortalId}", middleware.MapErrors(h.slogger, h.GetAPIPortal)) + mux.HandleFunc("PUT "+base+"/{apiPortalId}", middleware.MapErrors(h.slogger, h.UpdateAPIPortal)) + mux.HandleFunc("DELETE "+base+"/{apiPortalId}", middleware.MapErrors(h.slogger, h.DeleteAPIPortal)) +} + +// --- translation helpers --- + +func deref(p *string) string { + if p == nil { + return "" + } + return *p +} + +func derefConfig(c *api.ApiPortalConfig) map[string]interface{} { + if c == nil { + return nil + } + return map[string]interface{}(*c) +} + +func modelToAPIPortalResponse(p *model.APIPortal) *api.ApiPortalResponse { + if p == nil { + return nil + } + id := p.Handle + handle := p.Handle + createdAt := p.CreatedAt + updatedAt := p.UpdatedAt + + resp := &api.ApiPortalResponse{ + Id: &id, + Handle: &handle, + Name: p.Name, + AuthType: api.ApiPortalResponseAuthType(p.AuthType), + WorkflowStatus: api.ApiPortalResponseWorkflowStatus(p.WorkflowStatus), + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + } + if p.Description != "" { + desc := p.Description + resp.Description = &desc + } + if p.URL != "" { + url := p.URL + resp.Url = &url + } + if p.Configuration != nil { + cfg := api.ApiPortalConfig(p.Configuration) + resp.Config = &cfg + } + return resp +} + +func modelToAPIPortalListItem(p *model.APIPortal) api.ApiPortalListItem { + item := api.ApiPortalListItem{ + Id: p.Handle, + Handle: p.Handle, + Name: p.Name, + AuthType: api.ApiPortalListItemAuthType(p.AuthType), + WorkflowStatus: api.ApiPortalListItemWorkflowStatus(p.WorkflowStatus), + CreatedAt: p.CreatedAt, + } + if p.Description != "" { + desc := p.Description + item.Description = &desc + } + if p.URL != "" { + url := p.URL + item.Url = &url + } + return item +} + +func apiPortalListResponse(resp *service.APIPortalListResponse) *api.ApiPortalListResponse { + out := &api.ApiPortalListResponse{ + Count: resp.Count, + List: make([]api.ApiPortalListItem, 0, len(resp.List)), + Pagination: api.Pagination{ + Total: resp.Pagination.Total, + Offset: resp.Pagination.Offset, + Limit: resp.Pagination.Limit, + }, + } + for _, p := range resp.List { + out.List = append(out.List, modelToAPIPortalListItem(p)) + } + return out +} diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index 134420f811..ea26bb9618 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -140,6 +140,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, apiKeyRepo := repository.NewAPIKeyRepo(db, artifactTableRegistry) auditRepo := repository.NewAuditRepo(db) secretRepo := repository.NewSecretRepo(db) + apiPortalRepo := repository.NewAPIPortalRepo(db) userIdentityMappingRepo := repository.NewUserIdentityMappingRepo(db) userOrgMappingRepo := repository.NewUserOrganizationMappingRepo(db) @@ -247,6 +248,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, projectService := service.NewProjectService(projectRepo, orgRepo, apiRepo, mcpProxyRepo, appRepo, auditRepo, identityService, slogger) gatewayEventsService := service.NewGatewayEventsService(eventHub, identityService, slogger) appService := service.NewApplicationService(appRepo, projectRepo, orgRepo, apiRepo, gatewayEventsService, auditRepo, identityService, slogger) + apiPortalService := service.NewAPIPortalService(apiPortalRepo, orgRepo, auditRepo, identityService, slogger) apiService := service.NewAPIService(apiRepo, projectRepo, orgRepo, gatewayRepo, deploymentRepo, subscriptionPlanRepo, customPolicyRepo, gatewayEventsService, apiUtil, slogger, auditRepo, identityService) gatewayService := service.NewGatewayService(gatewayRepo, orgRepo, apiRepo, customPolicyRepo, gatewayEventsService, slogger, cfg.Gateway.EnableVersionVerification, cfg.Gateway.EnableFunctionalityTypeVerification, auditRepo, identityService) @@ -333,6 +335,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService, subscriptionPlanService, identityService, slogger) subscriptionPlanHandler := handler.NewSubscriptionPlanHandler(subscriptionPlanService, identityService, slogger) appHandler := handler.NewApplicationHandler(appService, identityService, cfg.Auth.Authorization.Mode, slogger) + apiPortalHandler := handler.NewAPIPortalHandler(apiPortalService, identityService, slogger) wsHandler := handler.NewWebSocketHandler(wsManager, gatewayService, deploymentService, cfg.Listeners.WebSocket.RateLimitPerMin, slogger) internalGatewayHandler := handler.NewGatewayInternalAPIHandler(gatewayService, internalGatewayService, artifactImportService, secretService, slogger) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService, identityService, cfg.Auth.Authorization.Mode, slogger) @@ -388,6 +391,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, orgHandler.RegisterRoutes(core) projectHandler.RegisterRoutes(core) appHandler.RegisterRoutes(core) + apiPortalHandler.RegisterRoutes(core) apiHandler.RegisterRoutes(core) gatewayHandler.RegisterRoutes(core) subscriptionHandler.RegisterRoutes(core) diff --git a/platform-api/internal/service/api_portal.go b/platform-api/internal/service/api_portal.go new file mode 100644 index 0000000000..a0a966ab62 --- /dev/null +++ b/platform-api/internal/service/api_portal.go @@ -0,0 +1,305 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed 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 service + +import ( + "fmt" + "log/slog" + "strings" + + "github.com/google/uuid" + + "github.com/wso2/api-platform/platform-api/internal/apperror" + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/model" + "github.com/wso2/api-platform/platform-api/internal/repository" + "github.com/wso2/api-platform/platform-api/internal/utils" +) + +// APIPortalService encapsulates business logic for the /api-portals resource. +// The handler layer translates OpenAPI-generated request/response DTOs into +// the service's own request structs so the service stays independent of the +// generated code. +type APIPortalService struct { + portalRepo repository.APIPortalRepository + orgRepo repository.OrganizationRepository + auditRepo repository.AuditRepository + identity *IdentityService + slogger *slog.Logger +} + +// NewAPIPortalService constructs an APIPortalService. +func NewAPIPortalService( + portalRepo repository.APIPortalRepository, + orgRepo repository.OrganizationRepository, + auditRepo repository.AuditRepository, + identity *IdentityService, + slogger *slog.Logger, +) *APIPortalService { + return &APIPortalService{ + portalRepo: portalRepo, + orgRepo: orgRepo, + auditRepo: auditRepo, + identity: identity, + slogger: slogger, + } +} + +// CreateAPIPortalRequest is the service-layer input for creating an API Portal. +// Fields mirror the OpenAPI CreateApiPortalRequest but stay independent of the +// generated types. +type CreateAPIPortalRequest struct { + Handle string + Name string + Description string + URL string + WorkflowStatus string // optional; defaults to "pending" + AuthType string + Configuration map[string]interface{} +} + +// UpdateAPIPortalRequest carries mutable fields for a partial update. Pointer +// fields distinguish "not sent" (nil) from "sent as empty" (non-nil, empty). +// Only whitelisted fields are respected here; Handle, ID, OrganizationID, +// CreatedAt, CreatedBy are ignored per the design's immutability rules. +type UpdateAPIPortalRequest struct { + Name *string + Description *string + URL *string + WorkflowStatus *string + AuthType *string + Configuration map[string]interface{} // when nil, the existing configuration is preserved +} + +// APIPortalListOptions bundles the pagination + filter inputs for List. +type APIPortalListOptions struct { + repository.ListOptions + WorkflowStatus *string +} + +// APIPortalListResponse is the service-layer list result. The handler wraps +// this in the OpenAPI-generated envelope. +type APIPortalListResponse struct { + Count int + List []*model.APIPortal + Pagination PaginationInfo +} + +// PaginationInfo is the {total, offset, limit} triplet returned in list responses. +type PaginationInfo struct { + Total int + Offset int + Limit int +} + +// CreateAPIPortal validates the request, enforces uniqueness of the handle, +// and inserts a new row scoped to orgID. +func (s *APIPortalService) CreateAPIPortal(req *CreateAPIPortalRequest, orgID, createdBy string) (*model.APIPortal, error) { + if req == nil { + return nil, apperror.ValidationFailed.New("The request body is required.") + } + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, apperror.ValidationFailed.New("The name field is required.") + } + if err := utils.ValidateHandle(strings.TrimSpace(req.Handle)); err != nil { + return nil, err + } + authType := strings.TrimSpace(req.AuthType) + if !constants.ValidAPIPortalAuthTypes[authType] { + return nil, apperror.ValidationFailed.New( + fmt.Sprintf("The authType %q is not supported.", authType)) + } + workflowStatus := strings.TrimSpace(req.WorkflowStatus) + if workflowStatus == "" { + workflowStatus = constants.APIPortalWorkflowStatusPending + } else if !constants.ValidAPIPortalWorkflowStatuses[workflowStatus] { + return nil, apperror.ValidationFailed.New( + fmt.Sprintf("The workflowStatus %q is not supported.", workflowStatus)) + } + + org, err := s.orgRepo.GetOrganizationByUUID(orgID) + if err != nil { + return nil, err + } + if org == nil { + return nil, apperror.OrganizationNotFound.New() + } + + exists, err := s.portalRepo.Exists(strings.TrimSpace(req.Handle), orgID) + if err != nil { + return nil, err + } + if exists { + return nil, apperror.APIPortalExists.New() + } + + actor := strings.TrimSpace(createdBy) + portal := &model.APIPortal{ + ID: uuid.New().String(), + OrganizationID: orgID, + Handle: strings.TrimSpace(req.Handle), + Name: name, + Description: strings.TrimSpace(req.Description), + URL: strings.TrimSpace(req.URL), + WorkflowStatus: workflowStatus, + AuthType: authType, + Configuration: req.Configuration, + CreatedBy: actor, + UpdatedBy: actor, + } + + if err := s.portalRepo.Create(portal); err != nil { + if repository.IsUniqueViolation(err) { + // A concurrent create won the race between Exists and INSERT. + return nil, apperror.APIPortalExists.New() + } + return nil, err + } + _ = s.auditRepo.Record("CREATE", portal.ID, "api_portal", orgID, actor) + return portal, nil +} + +// GetAPIPortal returns a single API Portal identified by its handle (wire ID) within orgID. +func (s *APIPortalService) GetAPIPortal(handle, orgID string) (*model.APIPortal, error) { + portal, err := s.portalRepo.GetByHandleAndOrgID(strings.TrimSpace(handle), orgID) + if err != nil { + return nil, err + } + if portal == nil { + return nil, apperror.APIPortalNotFound.New() + } + return portal, nil +} + +// ListAPIPortals returns a page of API Portals in the organization, honoring +// the requested pagination + filter options. Limit/Offset are normalized here. +func (s *APIPortalService) ListAPIPortals(orgID string, opts APIPortalListOptions) (*APIPortalListResponse, error) { + org, err := s.orgRepo.GetOrganizationByUUID(orgID) + if err != nil { + return nil, err + } + if org == nil { + return nil, apperror.OrganizationNotFound.New() + } + if opts.Limit <= 0 { + opts.Limit = 20 + } + if opts.Limit > 100 { + opts.Limit = 100 + } + if opts.Offset < 0 { + opts.Offset = 0 + } + if opts.WorkflowStatus != nil { + trimmed := strings.TrimSpace(*opts.WorkflowStatus) + if trimmed == "" { + opts.WorkflowStatus = nil + } else if !constants.ValidAPIPortalWorkflowStatuses[trimmed] { + return nil, apperror.ValidationFailed.New( + fmt.Sprintf("The workflowStatus %q is not supported.", trimmed)) + } else { + opts.WorkflowStatus = &trimmed + } + } + + total, err := s.portalRepo.Count(orgID, opts.WorkflowStatus, opts.Search) + if err != nil { + return nil, err + } + page, err := s.portalRepo.ListPaginated(orgID, opts.WorkflowStatus, opts.ListOptions) + if err != nil { + return nil, err + } + return &APIPortalListResponse{ + Count: len(page), + List: page, + Pagination: PaginationInfo{Total: total, Offset: opts.Offset, Limit: opts.Limit}, + }, nil +} + +// UpdateAPIPortal loads the row, applies only the whitelisted mutations from req, +// persists the change, and returns the updated row. +func (s *APIPortalService) UpdateAPIPortal(handle string, req *UpdateAPIPortalRequest, orgID, updatedBy string) (*model.APIPortal, error) { + if req == nil { + return nil, apperror.ValidationFailed.New("The request body is required.") + } + portal, err := s.portalRepo.GetByHandleAndOrgID(strings.TrimSpace(handle), orgID) + if err != nil { + return nil, err + } + if portal == nil { + return nil, apperror.APIPortalNotFound.New() + } + + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name == "" { + return nil, apperror.ValidationFailed.New("The name field cannot be empty.") + } + portal.Name = name + } + if req.Description != nil { + portal.Description = strings.TrimSpace(*req.Description) + } + if req.URL != nil { + portal.URL = strings.TrimSpace(*req.URL) + } + if req.WorkflowStatus != nil { + ws := strings.TrimSpace(*req.WorkflowStatus) + if !constants.ValidAPIPortalWorkflowStatuses[ws] { + return nil, apperror.ValidationFailed.New( + fmt.Sprintf("The workflowStatus %q is not supported.", ws)) + } + portal.WorkflowStatus = ws + } + if req.AuthType != nil { + at := strings.TrimSpace(*req.AuthType) + if !constants.ValidAPIPortalAuthTypes[at] { + return nil, apperror.ValidationFailed.New( + fmt.Sprintf("The authType %q is not supported.", at)) + } + portal.AuthType = at + } + if req.Configuration != nil { + portal.Configuration = req.Configuration + } + portal.UpdatedBy = strings.TrimSpace(updatedBy) + + if err := s.portalRepo.Update(portal); err != nil { + return nil, err + } + _ = s.auditRepo.Record("UPDATE", portal.ID, "api_portal", orgID, portal.UpdatedBy) + return portal, nil +} + +// DeleteAPIPortal removes the API Portal identified by its handle, org-scoped. +func (s *APIPortalService) DeleteAPIPortal(handle, orgID, actor string) error { + portal, err := s.portalRepo.GetByHandleAndOrgID(strings.TrimSpace(handle), orgID) + if err != nil { + return err + } + if portal == nil { + return apperror.APIPortalNotFound.New() + } + if err := s.portalRepo.Delete(portal.ID, orgID); err != nil { + return err + } + _ = s.auditRepo.Record("DELETE", portal.ID, "api_portal", orgID, strings.TrimSpace(actor)) + return nil +} diff --git a/platform-api/internal/service/api_portal_test.go b/platform-api/internal/service/api_portal_test.go new file mode 100644 index 0000000000..0a029e4722 --- /dev/null +++ b/platform-api/internal/service/api_portal_test.go @@ -0,0 +1,465 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed 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 service + +import ( + "errors" + "testing" + + "github.com/wso2/api-platform/platform-api/internal/apperror" + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/model" + "github.com/wso2/api-platform/platform-api/internal/repository" +) + +// --- mocks --- +// Each mock embeds the interface so unimplemented methods panic on invocation, +// making it obvious when a test exercises an unstubbed code path. + +type mockAPIPortalRepository struct { + repository.APIPortalRepository + + existsResult bool + existsErr error + + createErr error + createReturnUnique bool // if true, Create returns a canned unique-violation + createCapturedInput *model.APIPortal + + getResult *model.APIPortal + getErr error + + listResult []*model.APIPortal + listErr error + + countResult int + countErr error + + updateErr error + updateCapturedInput *model.APIPortal + + deleteCalledWith [2]string + deleteErr error +} + +// canned unique-violation error — matches IsUniqueViolation's SQLite substring. +var errCannedUnique = errors.New("UNIQUE constraint failed: api_portals.handle") + +func (m *mockAPIPortalRepository) Exists(handle, orgUUID string) (bool, error) { + return m.existsResult, m.existsErr +} + +func (m *mockAPIPortalRepository) Create(portal *model.APIPortal) error { + m.createCapturedInput = portal + if m.createReturnUnique { + return errCannedUnique + } + return m.createErr +} + +func (m *mockAPIPortalRepository) GetByHandleAndOrgID(handle, orgUUID string) (*model.APIPortal, error) { + return m.getResult, m.getErr +} + +func (m *mockAPIPortalRepository) ListPaginated(orgUUID string, workflowStatus *string, opts repository.ListOptions) ([]*model.APIPortal, error) { + return m.listResult, m.listErr +} + +func (m *mockAPIPortalRepository) Count(orgUUID string, workflowStatus *string, search string) (int, error) { + return m.countResult, m.countErr +} + +func (m *mockAPIPortalRepository) Update(portal *model.APIPortal) error { + m.updateCapturedInput = portal + return m.updateErr +} + +func (m *mockAPIPortalRepository) Delete(portalID, orgUUID string) error { + m.deleteCalledWith = [2]string{portalID, orgUUID} + return m.deleteErr +} + +type mockAPIPortalOrgRepository struct { + repository.OrganizationRepository + result *model.Organization + err error +} + +func (m *mockAPIPortalOrgRepository) GetOrganizationByUUID(uuid string) (*model.Organization, error) { + return m.result, m.err +} + +type mockAPIPortalAuditRepository struct { + repository.AuditRepository + records []auditRecord +} + +type auditRecord struct { + action string + resourceUUID string + resourceType string + orgUUID string + performedBy string +} + +func (m *mockAPIPortalAuditRepository) Record(action, resourceUUID, resourceType, orgUUID, performedBy string) error { + m.records = append(m.records, auditRecord{action, resourceUUID, resourceType, orgUUID, performedBy}) + return nil +} + +// newTestAPIPortalService wires the three mocks together. identity + slogger +// are nil because the service does not invoke them. +func newTestAPIPortalService( + portalRepo repository.APIPortalRepository, + orgRepo repository.OrganizationRepository, + auditRepo repository.AuditRepository, +) *APIPortalService { + return NewAPIPortalService(portalRepo, orgRepo, auditRepo, nil, nil) +} + +func apiPortalStrPtr(s string) *string { return &s } + +// --- Create tests --- + +func TestAPIPortalService_CreateAPIPortal_HappyPath(t *testing.T) { + portalRepo := &mockAPIPortalRepository{} + orgRepo := &mockAPIPortalOrgRepository{result: &model.Organization{}} + auditRepo := &mockAPIPortalAuditRepository{} + svc := newTestAPIPortalService(portalRepo, orgRepo, auditRepo) + + req := &CreateAPIPortalRequest{ + Handle: "acme", + Name: "Acme Portal", + Description: "test", + URL: "https://acme.example.com", + AuthType: constants.APIPortalAuthTypeLocal, + Configuration: map[string]interface{}{"key": "value"}, + } + got, err := svc.CreateAPIPortal(req, "org-1", "user-1") + if err != nil { + t.Fatalf("CreateAPIPortal: %v", err) + } + if got == nil || got.Handle != "acme" || got.Name != "Acme Portal" { + t.Errorf("returned portal wrong shape: %+v", got) + } + if got.WorkflowStatus != constants.APIPortalWorkflowStatusPending { + t.Errorf("default workflowStatus: want pending, got %q", got.WorkflowStatus) + } + if got.ID == "" { + t.Error("expected generated UUID, got empty") + } + if got.CreatedBy != "user-1" || got.UpdatedBy != "user-1" { + t.Errorf("actor not populated: createdBy=%q updatedBy=%q", got.CreatedBy, got.UpdatedBy) + } + if portalRepo.createCapturedInput == nil { + t.Error("repository Create not called") + } + if len(auditRepo.records) != 1 || auditRepo.records[0].action != "CREATE" { + t.Errorf("expected 1 CREATE audit record, got %+v", auditRepo.records) + } +} + +func TestAPIPortalService_CreateAPIPortal_MissingName(t *testing.T) { + svc := newTestAPIPortalService(&mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}) + _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ + Handle: "acme", + AuthType: constants.APIPortalAuthTypeLocal, + }, "org-1", "user-1") + if err == nil { + t.Fatal("expected error for missing name") + } + if !apperror.ValidationFailed.Is(err) { + t.Errorf("want ValidationFailed, got %v", err) + } +} + +func TestAPIPortalService_CreateAPIPortal_InvalidHandle(t *testing.T) { + svc := newTestAPIPortalService(&mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}) + _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ + Handle: "AB", // too short + uppercase + Name: "x", + AuthType: constants.APIPortalAuthTypeLocal, + }, "org-1", "user-1") + if err == nil { + t.Fatal("expected error for invalid handle") + } +} + +func TestAPIPortalService_CreateAPIPortal_InvalidAuthType(t *testing.T) { + svc := newTestAPIPortalService(&mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}) + _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ + Handle: "acme", + Name: "Acme", + AuthType: "bogus", + }, "org-1", "user-1") + if err == nil { + t.Fatal("expected error for invalid authType") + } + if !apperror.ValidationFailed.Is(err) { + t.Errorf("want ValidationFailed, got %v", err) + } +} + +func TestAPIPortalService_CreateAPIPortal_InvalidWorkflowStatus(t *testing.T) { + svc := newTestAPIPortalService(&mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}) + _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ + Handle: "acme", + Name: "Acme", + AuthType: constants.APIPortalAuthTypeLocal, + WorkflowStatus: "not-a-real-status", + }, "org-1", "user-1") + if err == nil { + t.Fatal("expected error for invalid workflowStatus") + } +} + +func TestAPIPortalService_CreateAPIPortal_OrgNotFound(t *testing.T) { + svc := newTestAPIPortalService(&mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: nil}, &mockAPIPortalAuditRepository{}) + _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ + Handle: "acme", Name: "Acme", AuthType: constants.APIPortalAuthTypeLocal, + }, "org-missing", "user-1") + if err == nil || !apperror.OrganizationNotFound.Is(err) { + t.Fatalf("want OrganizationNotFound, got %v", err) + } +} + +func TestAPIPortalService_CreateAPIPortal_HandleAlreadyExists(t *testing.T) { + svc := newTestAPIPortalService( + &mockAPIPortalRepository{existsResult: true}, + &mockAPIPortalOrgRepository{result: &model.Organization{}}, + &mockAPIPortalAuditRepository{}, + ) + _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ + Handle: "acme", Name: "Acme", AuthType: constants.APIPortalAuthTypeLocal, + }, "org-1", "user-1") + if err == nil || !apperror.APIPortalExists.Is(err) { + t.Fatalf("want APIPortalExists, got %v", err) + } +} + +func TestAPIPortalService_CreateAPIPortal_RaceOnUniqueConstraint(t *testing.T) { + // Exists() returns false (no row yet), then Create() races against another + // insert and hits the UNIQUE constraint. Service must translate to Conflict. + svc := newTestAPIPortalService( + &mockAPIPortalRepository{existsResult: false, createReturnUnique: true}, + &mockAPIPortalOrgRepository{result: &model.Organization{}}, + &mockAPIPortalAuditRepository{}, + ) + _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ + Handle: "acme", Name: "Acme", AuthType: constants.APIPortalAuthTypeLocal, + }, "org-1", "user-1") + if err == nil || !apperror.APIPortalExists.Is(err) { + t.Fatalf("want APIPortalExists on race, got %v", err) + } +} + +// --- Get tests --- + +func TestAPIPortalService_GetAPIPortal_HappyPath(t *testing.T) { + portal := &model.APIPortal{ID: "p1", Handle: "acme", OrganizationID: "org-1"} + svc := newTestAPIPortalService( + &mockAPIPortalRepository{getResult: portal}, + &mockAPIPortalOrgRepository{result: &model.Organization{}}, + &mockAPIPortalAuditRepository{}, + ) + got, err := svc.GetAPIPortal("acme", "org-1") + if err != nil { + t.Fatalf("GetAPIPortal: %v", err) + } + if got != portal { + t.Errorf("want %p, got %p", portal, got) + } +} + +func TestAPIPortalService_GetAPIPortal_NotFound(t *testing.T) { + svc := newTestAPIPortalService(&mockAPIPortalRepository{getResult: nil}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) + _, err := svc.GetAPIPortal("ghost", "org-1") + if err == nil || !apperror.APIPortalNotFound.Is(err) { + t.Fatalf("want APIPortalNotFound, got %v", err) + } +} + +// --- List tests --- + +func TestAPIPortalService_ListAPIPortals_HappyPath(t *testing.T) { + portals := []*model.APIPortal{{ID: "p1", Handle: "a"}, {ID: "p2", Handle: "b"}} + svc := newTestAPIPortalService( + &mockAPIPortalRepository{listResult: portals, countResult: 5}, + &mockAPIPortalOrgRepository{result: &model.Organization{}}, + &mockAPIPortalAuditRepository{}, + ) + resp, err := svc.ListAPIPortals("org-1", APIPortalListOptions{}) + if err != nil { + t.Fatalf("ListAPIPortals: %v", err) + } + if resp.Count != 2 || resp.Pagination.Total != 5 { + t.Errorf("counts wrong: %+v", resp) + } + if resp.Pagination.Limit != 20 { // default + t.Errorf("default limit not applied: %d", resp.Pagination.Limit) + } +} + +func TestAPIPortalService_ListAPIPortals_OrgNotFound(t *testing.T) { + svc := newTestAPIPortalService(&mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: nil}, &mockAPIPortalAuditRepository{}) + _, err := svc.ListAPIPortals("org-missing", APIPortalListOptions{}) + if err == nil || !apperror.OrganizationNotFound.Is(err) { + t.Fatalf("want OrganizationNotFound, got %v", err) + } +} + +func TestAPIPortalService_ListAPIPortals_LimitClamping(t *testing.T) { + svc := newTestAPIPortalService( + &mockAPIPortalRepository{listResult: nil, countResult: 0}, + &mockAPIPortalOrgRepository{result: &model.Organization{}}, + &mockAPIPortalAuditRepository{}, + ) + resp, err := svc.ListAPIPortals("org-1", APIPortalListOptions{ListOptions: repository.ListOptions{Limit: 500, Offset: -5}}) + if err != nil { + t.Fatalf("ListAPIPortals: %v", err) + } + if resp.Pagination.Limit != 100 { + t.Errorf("limit not clamped to 100: %d", resp.Pagination.Limit) + } + if resp.Pagination.Offset != 0 { + t.Errorf("negative offset not normalized to 0: %d", resp.Pagination.Offset) + } +} + +func TestAPIPortalService_ListAPIPortals_InvalidWorkflowStatus(t *testing.T) { + svc := newTestAPIPortalService(&mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}) + _, err := svc.ListAPIPortals("org-1", APIPortalListOptions{WorkflowStatus: apiPortalStrPtr("bogus")}) + if err == nil || !apperror.ValidationFailed.Is(err) { + t.Fatalf("want ValidationFailed, got %v", err) + } +} + +// --- Update tests --- + +func TestAPIPortalService_UpdateAPIPortal_HappyPath(t *testing.T) { + existing := &model.APIPortal{ + ID: "p1", Handle: "acme", OrganizationID: "org-1", + Name: "old", WorkflowStatus: constants.APIPortalWorkflowStatusPending, + AuthType: constants.APIPortalAuthTypeLocal, + } + portalRepo := &mockAPIPortalRepository{getResult: existing} + auditRepo := &mockAPIPortalAuditRepository{} + svc := newTestAPIPortalService(portalRepo, &mockAPIPortalOrgRepository{}, auditRepo) + + req := &UpdateAPIPortalRequest{ + Name: apiPortalStrPtr("Renamed"), + WorkflowStatus: apiPortalStrPtr(constants.APIPortalWorkflowStatusActive), + AuthType: apiPortalStrPtr(constants.APIPortalAuthTypeOAuth2), + Configuration: map[string]interface{}{"stsTokenUrl": "https://sts"}, + } + got, err := svc.UpdateAPIPortal("acme", req, "org-1", "editor") + if err != nil { + t.Fatalf("UpdateAPIPortal: %v", err) + } + if got.Name != "Renamed" || got.WorkflowStatus != constants.APIPortalWorkflowStatusActive || + got.AuthType != constants.APIPortalAuthTypeOAuth2 { + t.Errorf("mutable fields not applied: %+v", got) + } + if got.Handle != "acme" || got.ID != "p1" { + t.Errorf("immutable fields changed: %+v", got) + } + if got.UpdatedBy != "editor" { + t.Errorf("updatedBy not populated: %q", got.UpdatedBy) + } + if portalRepo.updateCapturedInput == nil { + t.Error("repository Update not called") + } + if len(auditRepo.records) != 1 || auditRepo.records[0].action != "UPDATE" { + t.Errorf("expected 1 UPDATE audit record, got %+v", auditRepo.records) + } +} + +func TestAPIPortalService_UpdateAPIPortal_PartialUpdate(t *testing.T) { + existing := &model.APIPortal{ + ID: "p1", Handle: "acme", OrganizationID: "org-1", + Name: "keep", URL: "https://keep.example.com", + WorkflowStatus: constants.APIPortalWorkflowStatusActive, + AuthType: constants.APIPortalAuthTypeLocal, + } + svc := newTestAPIPortalService(&mockAPIPortalRepository{getResult: existing}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) + // Only Description supplied; everything else must remain unchanged. + got, err := svc.UpdateAPIPortal("acme", &UpdateAPIPortalRequest{Description: apiPortalStrPtr("new desc")}, "org-1", "editor") + if err != nil { + t.Fatalf("UpdateAPIPortal: %v", err) + } + if got.Description != "new desc" { + t.Errorf("Description not updated: %q", got.Description) + } + if got.Name != "keep" || got.URL != "https://keep.example.com" || + got.WorkflowStatus != constants.APIPortalWorkflowStatusActive || + got.AuthType != constants.APIPortalAuthTypeLocal { + t.Errorf("unset fields were mutated: %+v", got) + } +} + +func TestAPIPortalService_UpdateAPIPortal_NotFound(t *testing.T) { + svc := newTestAPIPortalService(&mockAPIPortalRepository{getResult: nil}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) + _, err := svc.UpdateAPIPortal("ghost", &UpdateAPIPortalRequest{Name: apiPortalStrPtr("x")}, "org-1", "editor") + if err == nil || !apperror.APIPortalNotFound.Is(err) { + t.Fatalf("want APIPortalNotFound, got %v", err) + } +} + +func TestAPIPortalService_UpdateAPIPortal_EmptyName(t *testing.T) { + existing := &model.APIPortal{ID: "p1", Handle: "acme", OrganizationID: "org-1", Name: "old"} + svc := newTestAPIPortalService(&mockAPIPortalRepository{getResult: existing}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) + _, err := svc.UpdateAPIPortal("acme", &UpdateAPIPortalRequest{Name: apiPortalStrPtr(" ")}, "org-1", "editor") + if err == nil || !apperror.ValidationFailed.Is(err) { + t.Fatalf("want ValidationFailed for empty name, got %v", err) + } +} + +func TestAPIPortalService_UpdateAPIPortal_InvalidWorkflowStatus(t *testing.T) { + existing := &model.APIPortal{ID: "p1", Handle: "acme", OrganizationID: "org-1"} + svc := newTestAPIPortalService(&mockAPIPortalRepository{getResult: existing}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) + _, err := svc.UpdateAPIPortal("acme", &UpdateAPIPortalRequest{WorkflowStatus: apiPortalStrPtr("bogus")}, "org-1", "editor") + if err == nil || !apperror.ValidationFailed.Is(err) { + t.Fatalf("want ValidationFailed, got %v", err) + } +} + +// --- Delete tests --- + +func TestAPIPortalService_DeleteAPIPortal_HappyPath(t *testing.T) { + existing := &model.APIPortal{ID: "p1", Handle: "acme", OrganizationID: "org-1"} + portalRepo := &mockAPIPortalRepository{getResult: existing} + auditRepo := &mockAPIPortalAuditRepository{} + svc := newTestAPIPortalService(portalRepo, &mockAPIPortalOrgRepository{}, auditRepo) + if err := svc.DeleteAPIPortal("acme", "org-1", "actor"); err != nil { + t.Fatalf("DeleteAPIPortal: %v", err) + } + if portalRepo.deleteCalledWith != [2]string{"p1", "org-1"} { + t.Errorf("Delete called with wrong args: %+v", portalRepo.deleteCalledWith) + } + if len(auditRepo.records) != 1 || auditRepo.records[0].action != "DELETE" { + t.Errorf("expected 1 DELETE audit record, got %+v", auditRepo.records) + } +} + +func TestAPIPortalService_DeleteAPIPortal_NotFound(t *testing.T) { + svc := newTestAPIPortalService(&mockAPIPortalRepository{getResult: nil}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) + err := svc.DeleteAPIPortal("ghost", "org-1", "actor") + if err == nil || !apperror.APIPortalNotFound.Is(err) { + t.Fatalf("want APIPortalNotFound, got %v", err) + } +} diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index 46f76544e1..b8005b7d0b 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -4455,6 +4455,175 @@ paths: '500': $ref: '#/components/responses/InternalServerError' + /api-portals: + post: + summary: Create an API Portal + description: | + Registers a new API Portal in the caller's organization. If a URL is + provided, the portal is registered against that existing endpoint; if + omitted, a new portal is provisioned and the URL is populated when the + instance becomes reachable. Organization ID is extracted from the JWT + token. + operationId: CreateApiPortal + security: + - OAuth2Security: + - ap:api_portal:create + - ap:api_portal:manage + tags: + - API Portals + requestBody: + description: API Portal registration details + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateApiPortalRequest' + responses: + '201': + description: API Portal created successfully + headers: + Location: + $ref: '#/components/headers/Location' + content: + application/json: + schema: + $ref: '#/components/schemas/ApiPortalResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' + '500': + $ref: '#/components/responses/InternalServerError' + get: + summary: List API Portals + description: Lists API Portals in the org resolved from the JWT token. + operationId: ListApiPortals + security: + - OAuth2Security: + - ap:api_portal:read + - ap:api_portal:manage + tags: + - API Portals + parameters: + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' + - $ref: '#/components/parameters/sortBy-Q' + - $ref: '#/components/parameters/sortOrder-Q' + - $ref: '#/components/parameters/query-Q' + - $ref: '#/components/parameters/apiPortalWorkflowStatus-Q' + responses: + '200': + description: API Portals retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ApiPortalListResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalServerError' + + /api-portals/{apiPortalId}: + get: + summary: Get API Portal by ID + description: Reads a single API Portal by its handle. Access is validated against the org in the JWT token. + operationId: GetApiPortal + security: + - OAuth2Security: + - ap:api_portal:read + - ap:api_portal:manage + tags: + - API Portals + parameters: + - $ref: '#/components/parameters/apiPortalId' + responses: + '200': + description: API Portal retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ApiPortalResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + put: + summary: Update API Portal + description: | + Updates mutable fields on an API Portal. The server ignores any immutable + field appearing in the body. Access is validated against the org in the JWT token. + operationId: UpdateApiPortal + security: + - OAuth2Security: + - ap:api_portal:update + - ap:api_portal:manage + tags: + - API Portals + parameters: + - $ref: '#/components/parameters/apiPortalId' + requestBody: + description: API Portal fields to update + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateApiPortalRequest' + responses: + '200': + description: API Portal updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ApiPortalResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + delete: + summary: Delete API Portal + description: | + Deletes an API Portal. Any provisioned instance and associated identity-provider + credentials are cleaned up as part of deletion. Access is validated against the + org in the JWT token. + operationId: DeleteApiPortal + security: + - OAuth2Security: + - ap:api_portal:delete + - ap:api_portal:manage + tags: + - API Portals + parameters: + - $ref: '#/components/parameters/apiPortalId' + responses: + '204': + description: API Portal deleted successfully + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + /me/api-keys: get: summary: List API keys for the current user, or for all users with `ap:api_key:all:manage` @@ -4732,6 +4901,11 @@ components: scopes: ap:api_key:all:manage: Manage API keys created by any user in the organization ap:api_key:read: Read API keys owned by the current user + ap:api_portal:create: Create an API Portal + ap:api_portal:delete: Delete an API Portal + ap:api_portal:manage: Full access to API Portals + ap:api_portal:read: Read API Portals + ap:api_portal:update: Update an API Portal ap:application:api_key:create: Create an application API key ap:application:api_key:delete: Delete an application API key ap:application:api_key:manage: Full access to application API keys @@ -8650,6 +8824,210 @@ components: pagination: $ref: '#/components/schemas/Pagination' + ApiPortalConfig: + title: API Portal auth-type-specific config + type: object + description: | + Configuration for how Platform API authenticates to the portal's admin + API. Shape depends on `authType`; treated as an opaque object at the + wire level. + additionalProperties: true + + ApiPortalResponse: + title: API Portal detail + type: object + required: + - id + - name + - handle + - workflowStatus + - authType + - createdAt + - updatedAt + properties: + id: + type: string + description: Handle (URL-friendly slug) of the API Portal — primary identifier. + pattern: '^[a-z0-9-]+$' + minLength: 3 + maxLength: 40 + readOnly: true + example: "acme-portal" + name: + type: string + description: Display name. + minLength: 1 + maxLength: 255 + example: "Acme Developer Portal" + handle: + type: string + description: URL-friendly slug. Immutable after creation. Equal to `id`. + pattern: '^[a-z0-9-]+$' + minLength: 3 + maxLength: 40 + readOnly: true + example: "acme-portal" + description: + type: string + nullable: true + maxLength: 4000 + url: + type: string + format: uri + nullable: true + description: >- + Public URL of the API Portal. May be null while the portal is being + provisioned; populated once the instance is reachable. + example: "https://acme-portal.example.com" + workflowStatus: + type: string + enum: [pending, active, failed] + description: >- + Lifecycle state. `pending` — portal is being provisioned or activated. + `active` — portal is reachable and functional. `failed` — provisioning + or a subsequent health check failed. + example: "active" + authType: + type: string + enum: [local, oauth2] + description: >- + Determines how Platform API authenticates to the portal's admin API + and selects the shape of the `config` object. + example: "oauth2" + config: + $ref: '#/components/schemas/ApiPortalConfig' + createdAt: + type: string + format: date-time + readOnly: true + example: "2026-08-13T10:30:00Z" + updatedAt: + type: string + format: date-time + readOnly: true + example: "2026-08-13T10:30:00Z" + + ApiPortalListItem: + title: API Portal — list projection + description: Lightweight projection returned in collection responses (excludes the `config` blob). + type: object + required: + - id + - name + - handle + - workflowStatus + - authType + - createdAt + properties: + id: + type: string + pattern: '^[a-z0-9-]+$' + example: "acme-portal" + name: + type: string + example: "Acme Developer Portal" + handle: + type: string + pattern: '^[a-z0-9-]+$' + example: "acme-portal" + description: + type: string + nullable: true + url: + type: string + format: uri + nullable: true + workflowStatus: + type: string + enum: [pending, active, failed] + authType: + type: string + enum: [local, oauth2] + createdAt: + type: string + format: date-time + + CreateApiPortalRequest: + title: Create API Portal request + type: object + required: + - name + - handle + - authType + properties: + name: + type: string + minLength: 1 + maxLength: 255 + handle: + type: string + pattern: '^[a-z0-9-]+$' + minLength: 3 + maxLength: 40 + description: URL-friendly slug. Must be unique within the org. Immutable after creation. + description: + type: string + nullable: true + maxLength: 4000 + url: + type: string + format: uri + nullable: true + description: >- + Public URL of an existing API Portal to register. Omit to have a new + portal provisioned; the URL will be populated once the instance is + reachable. + authType: + type: string + enum: [local, oauth2] + config: + $ref: '#/components/schemas/ApiPortalConfig' + + UpdateApiPortalRequest: + title: Update API Portal request + description: All fields optional. Only mutable fields are accepted — see field permissions in the design doc. + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 255 + description: + type: string + nullable: true + maxLength: 4000 + url: + type: string + format: uri + nullable: true + workflowStatus: + type: string + enum: [pending, active, failed] + authType: + type: string + enum: [local, oauth2] + config: + $ref: '#/components/schemas/ApiPortalConfig' + + ApiPortalListResponse: + title: API Portal list response + type: object + required: + - count + - list + - pagination + properties: + count: + type: integer + description: Number of items in the current response page. + example: 2 + list: + type: array + items: + $ref: '#/components/schemas/ApiPortalListItem' + pagination: + $ref: '#/components/schemas/Pagination' + responses: Unauthorized: description: Unauthorized. Authentication credentials are missing or invalid. @@ -8991,6 +9369,29 @@ components: type: string example: payment + apiPortalId: + name: apiPortalId + in: path + required: true + description: | + **API Portal ID** consisting of the **handle** (unique slug identifier) of the API Portal. + schema: + type: string + pattern: '^[a-z0-9-]+$' + minLength: 3 + maxLength: 40 + example: "acme-portal" + + apiPortalWorkflowStatus-Q: + name: workflowStatus + in: query + required: false + description: Filter API Portals by lifecycle state. + schema: + type: string + enum: [pending, active, failed] + example: "active" + tags: - name: Health description: Health check endpoints @@ -9006,6 +9407,8 @@ tags: description: API deployment artifact management and lifecycle operations - name: API Portal description: API portal publishing and unpublishing operations + - name: API Portals + description: API Portal registration and management (CRUD on /api-portals) - name: DevPortals description: DevPortal management operations - name: Gateways diff --git a/platform-api/resources/role-to-scope-mapping.yaml b/platform-api/resources/role-to-scope-mapping.yaml index 5861ef6d7e..36750e9a54 100644 --- a/platform-api/resources/role-to-scope-mapping.yaml +++ b/platform-api/resources/role-to-scope-mapping.yaml @@ -79,6 +79,7 @@ roles: - ap:api_key:read # Administrative access to every user's API keys, not just the caller's. - ap:api_key:all:manage + - ap:api_portal:manage # - ap:websub_api:manage # event-gateway build only # - ap:webbroker_api:manage # event-gateway build only # API Portal & MCP Hub @@ -129,6 +130,7 @@ roles: - ap:subscription:read - ap:secret:read - ap:api_key:read + - ap:api_portal:manage # - ap:websub_api:read # event-gateway build only # - ap:websub_api:deployment:read # event-gateway build only # - ap:webbroker_api:read # event-gateway build only @@ -176,6 +178,7 @@ roles: - ap:subscription:read - ap:secret:manage - ap:api_key:read + - ap:api_portal:read # - ap:websub_api:manage # event-gateway build only # - ap:webbroker_api:manage # event-gateway build only # API Portal & MCP Hub @@ -256,6 +259,7 @@ roles: - ap:mcp_proxy:deployment:read - ap:secret:read - ap:api_key:read + - ap:api_portal:read # - ap:websub_api:read # event-gateway build only # - ap:websub_api:deployment:read # event-gateway build only # - ap:webbroker_api:read # event-gateway build only From 88df843bd1d62e9c2e2ff7c00b59965ff13d2688 Mon Sep 17 00:00:00 2001 From: dushaniw Date: Thu, 13 Aug 2026 12:58:44 +0530 Subject: [PATCH 06/25] add integration tests at handler level. --- .../handler/api_portal_integration_test.go | 427 ++++++++++++++++++ 1 file changed, 427 insertions(+) create mode 100644 platform-api/internal/handler/api_portal_integration_test.go diff --git a/platform-api/internal/handler/api_portal_integration_test.go b/platform-api/internal/handler/api_portal_integration_test.go new file mode 100644 index 0000000000..a57ff607ae --- /dev/null +++ b/platform-api/internal/handler/api_portal_integration_test.go @@ -0,0 +1,427 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed 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. + * + */ + +// Integration tests for the /api-portals handler, covering the full +// route → handler → service → repository stack backed by SQLite. + +package handler + +import ( + "bytes" + "database/sql" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/wso2/api-platform/platform-api/internal/database" + "github.com/wso2/api-platform/platform-api/internal/middleware" + "github.com/wso2/api-platform/platform-api/internal/repository" + "github.com/wso2/api-platform/platform-api/internal/service" + + _ "github.com/mattn/go-sqlite3" +) + +const apiPortalTestBase = "/api/v0.9/api-portals" +const apiPortalTestOrg = "org-portal-it" +const apiPortalTestUser = "sub-portal-tester" + +// setupAPIPortalHandlerEnv brings up the full API-Portal handler stack against a +// fresh SQLite database and seeds the parent organization row the FK requires. +func setupAPIPortalHandlerEnv(t *testing.T) (http.Handler, *database.DB, func()) { + t.Helper() + + dbPath := filepath.Join(t.TempDir(), "api-portal-test.db") + sqlDB, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + db := &database.DB{DB: sqlDB} + + schema, err := os.ReadFile(filepath.Join("..", "database", "schema.sqlite.sql")) + if err != nil { + t.Fatalf("read schema: %v", err) + } + if _, err = db.Exec(string(schema)); err != nil { + t.Fatalf("apply schema: %v", err) + } + if _, err = db.Exec( + `INSERT INTO organizations (uuid, handle, display_name, region, idp_organization_ref_uuid, created_at, updated_at) + VALUES (?, ?, 'Portal Test Org', 'default', 'idp-ref', datetime('now'), datetime('now'))`, + apiPortalTestOrg, "test-org-"+apiPortalTestOrg, + ); err != nil { + t.Fatalf("insert org: %v", err) + } + + portalRepo := repository.NewAPIPortalRepo(db) + orgRepo := repository.NewOrganizationRepo(db) + identityService := service.NewIdentityService(repository.NewUserIdentityMappingRepo(db)) + svc := service.NewAPIPortalService(portalRepo, orgRepo, noopAudit{}, identityService, slog.Default()) + h := NewAPIPortalHandler(svc, identityService, slog.Default()) + + mux := http.NewServeMux() + h.RegisterRoutes(mux) + return middleware.NewTestContextMiddleware(mux), db, func() { _ = sqlDB.Close() } +} + +// apiPortalTestRequest builds a request with the test auth headers set. +func apiPortalTestRequest(t *testing.T, method, path string, body []byte) *http.Request { + t.Helper() + var r *http.Request + if body != nil { + r = httptest.NewRequest(method, path, bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + } else { + r = httptest.NewRequest(method, path, nil) + } + r.Header.Set("X-Test-User", apiPortalTestUser) + r.Header.Set("X-Test-Org", apiPortalTestOrg) + return r +} + +func mustJSON(t *testing.T, v any) []byte { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return b +} + +// Minimal response shapes for decoding — mirror the fields the handler emits. +// Using a dedicated local shape avoids the pointer maze of api.ApiPortalResponse. +type apiPortalResp struct { + Id string `json:"id"` + Handle string `json:"handle"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Url *string `json:"url,omitempty"` + WorkflowStatus string `json:"workflowStatus"` + AuthType string `json:"authType"` + Config map[string]interface{} `json:"config,omitempty"` +} + +type apiPortalListResp struct { + Count int `json:"count"` + List []apiPortalResp `json:"list"` + Pagination struct { + Total int `json:"total"` + Offset int `json:"offset"` + Limit int `json:"limit"` + } `json:"pagination"` +} + +type apiPortalErrorResp struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// --- CREATE --- + +func TestAPIPortalHandler_Create_HappyPath(t *testing.T) { + r, _, cleanup := setupAPIPortalHandlerEnv(t) + t.Cleanup(cleanup) + + body := mustJSON(t, map[string]any{ + "name": "Acme Portal", + "handle": "acme", + "authType": "local", + "config": map[string]any{"foo": "bar"}, + }) + req := apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("Create: want 201, got %d: %s", rec.Code, rec.Body.String()) + } + loc := rec.Header().Get("Location") + if !strings.HasSuffix(loc, "/api-portals/acme") { + t.Errorf("Location header wrong: %q", loc) + } + var got apiPortalResp + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Id != "acme" || got.Handle != "acme" || got.Name != "Acme Portal" || + got.AuthType != "local" || got.WorkflowStatus != "pending" { + t.Errorf("response fields wrong: %+v", got) + } + if got.Config["foo"] != "bar" { + t.Errorf("config round-trip failed: %v", got.Config) + } +} + +func TestAPIPortalHandler_Create_MissingName(t *testing.T) { + r, _, cleanup := setupAPIPortalHandlerEnv(t) + t.Cleanup(cleanup) + + body := mustJSON(t, map[string]any{ + "handle": "acme", + "authType": "local", + }) + req := apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400 for missing name, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestAPIPortalHandler_Create_HandleConflict(t *testing.T) { + r, _, cleanup := setupAPIPortalHandlerEnv(t) + t.Cleanup(cleanup) + + body := mustJSON(t, map[string]any{"name": "a", "handle": "dup", "authType": "local"}) + req := apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("first Create: want 201, got %d: %s", rec.Code, rec.Body.String()) + } + + // Second POST with the same handle must be 409. + req2 := apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body) + rec2 := httptest.NewRecorder() + r.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusConflict { + t.Fatalf("duplicate Create: want 409, got %d: %s", rec2.Code, rec2.Body.String()) + } + var errBody apiPortalErrorResp + if err := json.Unmarshal(rec2.Body.Bytes(), &errBody); err != nil { + t.Fatalf("decode error body: %v", err) + } + if errBody.Code != "API_PORTAL_EXISTS" { + t.Errorf("error code: want API_PORTAL_EXISTS, got %q", errBody.Code) + } +} + +func TestAPIPortalHandler_Create_MissingOrg(t *testing.T) { + r, _, cleanup := setupAPIPortalHandlerEnv(t) + t.Cleanup(cleanup) + + body := mustJSON(t, map[string]any{"name": "a", "handle": "acme", "authType": "local"}) + // Deliberately DO NOT set X-Test-Org; expect 401 from the handler's org guard. + req := httptest.NewRequest(http.MethodPost, apiPortalTestBase, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Test-User", apiPortalTestUser) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("want 401 for missing org context, got %d: %s", rec.Code, rec.Body.String()) + } +} + +// --- GET (single) --- + +func TestAPIPortalHandler_Get_HappyPath(t *testing.T) { + r, _, cleanup := setupAPIPortalHandlerEnv(t) + t.Cleanup(cleanup) + + // Seed via POST. + body := mustJSON(t, map[string]any{"name": "Acme", "handle": "acme", "authType": "local"}) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body)) + if rec.Code != http.StatusCreated { + t.Fatalf("seed Create failed: %d %s", rec.Code, rec.Body.String()) + } + + getRec := httptest.NewRecorder() + r.ServeHTTP(getRec, apiPortalTestRequest(t, http.MethodGet, apiPortalTestBase+"/acme", nil)) + if getRec.Code != http.StatusOK { + t.Fatalf("Get: want 200, got %d: %s", getRec.Code, getRec.Body.String()) + } + var got apiPortalResp + if err := json.Unmarshal(getRec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Handle != "acme" || got.Name != "Acme" { + t.Errorf("Get response wrong: %+v", got) + } +} + +func TestAPIPortalHandler_Get_NotFound(t *testing.T) { + r, _, cleanup := setupAPIPortalHandlerEnv(t) + t.Cleanup(cleanup) + + rec := httptest.NewRecorder() + r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodGet, apiPortalTestBase+"/ghost", nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("Get missing: want 404, got %d: %s", rec.Code, rec.Body.String()) + } + var errBody apiPortalErrorResp + if err := json.Unmarshal(rec.Body.Bytes(), &errBody); err != nil { + t.Fatalf("decode error body: %v", err) + } + if errBody.Code != "API_PORTAL_NOT_FOUND" { + t.Errorf("error code: want API_PORTAL_NOT_FOUND, got %q", errBody.Code) + } +} + +// --- LIST --- + +func TestAPIPortalHandler_List_HappyPath(t *testing.T) { + r, _, cleanup := setupAPIPortalHandlerEnv(t) + t.Cleanup(cleanup) + + // Seed 3 portals. + for _, h := range []string{"one", "two", "three"} { + body := mustJSON(t, map[string]any{"name": "P " + h, "handle": h, "authType": "local"}) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body)) + if rec.Code != http.StatusCreated { + t.Fatalf("seed %s: %d %s", h, rec.Code, rec.Body.String()) + } + } + + rec := httptest.NewRecorder() + r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodGet, apiPortalTestBase, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("List: want 200, got %d: %s", rec.Code, rec.Body.String()) + } + var got apiPortalListResp + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Count != 3 || got.Pagination.Total != 3 || len(got.List) != 3 { + t.Errorf("counts wrong: %+v", got) + } + if got.Pagination.Limit != 20 { + t.Errorf("default limit: want 20, got %d", got.Pagination.Limit) + } +} + +func TestAPIPortalHandler_List_WorkflowStatusFilter(t *testing.T) { + r, db, cleanup := setupAPIPortalHandlerEnv(t) + t.Cleanup(cleanup) + + // Seed 3 portals (handle min length is 3). WorkflowStatus can't be set on + // Create body (it defaults to "pending" server-side), so bump one row via + // SQL directly to exercise the status filter. + for _, h := range []string{"aaa", "bbb", "ccc"} { + body := mustJSON(t, map[string]any{"name": "P " + h, "handle": h, "authType": "local"}) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body)) + if rec.Code != http.StatusCreated { + t.Fatalf("seed %s: %d %s", h, rec.Code, rec.Body.String()) + } + } + if _, err := db.Exec(`UPDATE api_portals SET workflow_status = 'active' WHERE handle = 'ccc'`); err != nil { + t.Fatalf("bump status: %v", err) + } + + rec := httptest.NewRecorder() + r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodGet, apiPortalTestBase+"?workflowStatus=active", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("List: want 200, got %d: %s", rec.Code, rec.Body.String()) + } + var got apiPortalListResp + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Count != 1 || got.List[0].Handle != "ccc" { + t.Errorf("filter miss: %+v", got) + } +} + +// --- UPDATE --- + +func TestAPIPortalHandler_Update_HappyPath(t *testing.T) { + r, _, cleanup := setupAPIPortalHandlerEnv(t) + t.Cleanup(cleanup) + + // Seed. + body := mustJSON(t, map[string]any{"name": "old", "handle": "acme", "authType": "local"}) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body)) + if rec.Code != http.StatusCreated { + t.Fatalf("seed: %d %s", rec.Code, rec.Body.String()) + } + + // Update name + authType. + patch := mustJSON(t, map[string]any{"name": "new", "authType": "oauth2"}) + putRec := httptest.NewRecorder() + r.ServeHTTP(putRec, apiPortalTestRequest(t, http.MethodPut, apiPortalTestBase+"/acme", patch)) + if putRec.Code != http.StatusOK { + t.Fatalf("Update: want 200, got %d: %s", putRec.Code, putRec.Body.String()) + } + var got apiPortalResp + if err := json.Unmarshal(putRec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Name != "new" || got.AuthType != "oauth2" { + t.Errorf("mutable fields not applied: %+v", got) + } + if got.Handle != "acme" { + t.Errorf("handle mutated: %q", got.Handle) + } +} + +func TestAPIPortalHandler_Update_NotFound(t *testing.T) { + r, _, cleanup := setupAPIPortalHandlerEnv(t) + t.Cleanup(cleanup) + + patch := mustJSON(t, map[string]any{"name": "x"}) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodPut, apiPortalTestBase+"/ghost", patch)) + if rec.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d: %s", rec.Code, rec.Body.String()) + } +} + +// --- DELETE --- + +func TestAPIPortalHandler_Delete_HappyPath(t *testing.T) { + r, _, cleanup := setupAPIPortalHandlerEnv(t) + t.Cleanup(cleanup) + + body := mustJSON(t, map[string]any{"name": "x", "handle": "gone", "authType": "local"}) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body)) + if rec.Code != http.StatusCreated { + t.Fatalf("seed: %d %s", rec.Code, rec.Body.String()) + } + + delRec := httptest.NewRecorder() + r.ServeHTTP(delRec, apiPortalTestRequest(t, http.MethodDelete, apiPortalTestBase+"/gone", nil)) + if delRec.Code != http.StatusNoContent { + t.Fatalf("Delete: want 204, got %d: %s", delRec.Code, delRec.Body.String()) + } + + // Subsequent Get is 404. + getRec := httptest.NewRecorder() + r.ServeHTTP(getRec, apiPortalTestRequest(t, http.MethodGet, apiPortalTestBase+"/gone", nil)) + if getRec.Code != http.StatusNotFound { + t.Fatalf("Get after Delete: want 404, got %d", getRec.Code) + } +} + +func TestAPIPortalHandler_Delete_NotFound(t *testing.T) { + r, _, cleanup := setupAPIPortalHandlerEnv(t) + t.Cleanup(cleanup) + + rec := httptest.NewRecorder() + r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodDelete, apiPortalTestBase+"/ghost", nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("Delete missing: want 404, got %d: %s", rec.Code, rec.Body.String()) + } +} From f6c666e651e7e7033ce95c1264e6963aede8898d Mon Sep 17 00:00:00 2001 From: dushaniw Date: Mon, 17 Aug 2026 12:54:49 +0530 Subject: [PATCH 07/25] Validate portal URL scheme on create and update CreateAPIPortal and UpdateAPIPortal now parse the portal URL through a new validateAPIPortalURL helper. Empty stays valid so cloud provisioning can register the row before the URL is known. Non-empty must be an absolute URL with a host and use the https scheme; anything else returns a validation error. Table-driven tests cover rejected schemes and malformed inputs; a positive test confirms https URLs with a port and path round-trip unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- platform-api/internal/service/api_portal.go | 44 ++++++++- .../internal/service/api_portal_test.go | 89 +++++++++++++++++++ 2 files changed, 131 insertions(+), 2 deletions(-) diff --git a/platform-api/internal/service/api_portal.go b/platform-api/internal/service/api_portal.go index a0a966ab62..1db1a21fa1 100644 --- a/platform-api/internal/service/api_portal.go +++ b/platform-api/internal/service/api_portal.go @@ -20,6 +20,7 @@ package service import ( "fmt" "log/slog" + "net/url" "strings" "github.com/google/uuid" @@ -31,6 +32,37 @@ import ( "github.com/wso2/api-platform/platform-api/internal/utils" ) +// validateAPIPortalURL enforces input-time constraints on a caller-supplied +// portal URL: +// - Empty is valid — the URL is populated later by the provisioner in the +// cloud flow, and OSS may register a portal before the URL is known. +// - Non-empty must parse as an absolute URL with a host, and use the https +// scheme. This blocks stored SSRF via `file://`, `javascript:`, and any +// plain-http URL that could be pointed at instance-metadata endpoints such +// as http://169.254.169.254/. +// +// Deeper outbound-hardening (private-IP blocklist, DNS-rebinding checks, +// redirect controls) is intentionally NOT enforced here — it belongs in the +// shared outbound HTTP client the publisher will build later, so every +// outbound integration gets the same protection uniformly. +func validateAPIPortalURL(raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", nil + } + u, err := url.Parse(trimmed) + if err != nil { + return "", apperror.ValidationFailed.New("The url field is not a valid URL.") + } + if !u.IsAbs() || u.Host == "" { + return "", apperror.ValidationFailed.New("The url field must be an absolute URL with a host.") + } + if u.Scheme != "https" { + return "", apperror.ValidationFailed.New("The url field must use the https scheme.") + } + return u.String(), nil +} + // APIPortalService encapsulates business logic for the /api-portals resource. // The handler layer translates OpenAPI-generated request/response DTOs into // the service's own request structs so the service stays independent of the @@ -132,6 +164,10 @@ func (s *APIPortalService) CreateAPIPortal(req *CreateAPIPortalRequest, orgID, c return nil, apperror.ValidationFailed.New( fmt.Sprintf("The workflowStatus %q is not supported.", workflowStatus)) } + portalURL, err := validateAPIPortalURL(req.URL) + if err != nil { + return nil, err + } org, err := s.orgRepo.GetOrganizationByUUID(orgID) if err != nil { @@ -156,7 +192,7 @@ func (s *APIPortalService) CreateAPIPortal(req *CreateAPIPortalRequest, orgID, c Handle: strings.TrimSpace(req.Handle), Name: name, Description: strings.TrimSpace(req.Description), - URL: strings.TrimSpace(req.URL), + URL: portalURL, WorkflowStatus: workflowStatus, AuthType: authType, Configuration: req.Configuration, @@ -258,7 +294,11 @@ func (s *APIPortalService) UpdateAPIPortal(handle string, req *UpdateAPIPortalRe portal.Description = strings.TrimSpace(*req.Description) } if req.URL != nil { - portal.URL = strings.TrimSpace(*req.URL) + portalURL, err := validateAPIPortalURL(*req.URL) + if err != nil { + return nil, err + } + portal.URL = portalURL } if req.WorkflowStatus != nil { ws := strings.TrimSpace(*req.WorkflowStatus) diff --git a/platform-api/internal/service/api_portal_test.go b/platform-api/internal/service/api_portal_test.go index 0a029e4722..8e72d6e2ac 100644 --- a/platform-api/internal/service/api_portal_test.go +++ b/platform-api/internal/service/api_portal_test.go @@ -268,6 +268,95 @@ func TestAPIPortalService_CreateAPIPortal_RaceOnUniqueConstraint(t *testing.T) { } } +func TestAPIPortalService_CreateAPIPortal_InvalidURL(t *testing.T) { + cases := []struct { + name string + url string + }{ + {"http_rejected", "http://portal.example.com"}, + {"file_scheme", "file:///etc/passwd"}, + {"metadata_service_http", "http://169.254.169.254/latest/meta-data/"}, + {"javascript_scheme", "javascript:alert(1)"}, + {"relative_url", "portal.example.com"}, + {"scheme_only", "https://"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc := newTestAPIPortalService( + &mockAPIPortalRepository{}, + &mockAPIPortalOrgRepository{result: &model.Organization{}}, + &mockAPIPortalAuditRepository{}, + ) + _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ + Handle: "acme", + Name: "Acme", + AuthType: constants.APIPortalAuthTypeLocal, + URL: tc.url, + }, "org-1", "user-1") + if err == nil || !apperror.ValidationFailed.Is(err) { + t.Errorf("want ValidationFailed for %q, got %v", tc.url, err) + } + }) + } +} + +func TestAPIPortalService_CreateAPIPortal_ValidHTTPSAccepted(t *testing.T) { + svc := newTestAPIPortalService( + &mockAPIPortalRepository{}, + &mockAPIPortalOrgRepository{result: &model.Organization{}}, + &mockAPIPortalAuditRepository{}, + ) + got, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ + Handle: "acme", + Name: "Acme", + AuthType: constants.APIPortalAuthTypeLocal, + URL: "https://portal.example.com:9443/base", + }, "org-1", "user-1") + if err != nil { + t.Fatalf("valid https URL rejected: %v", err) + } + if got.URL != "https://portal.example.com:9443/base" { + t.Errorf("URL not preserved: %q", got.URL) + } +} + +func TestAPIPortalService_CreateAPIPortal_EmptyURLAllowed(t *testing.T) { + // Cloud provisioning starts with URL null; empty must pass validation. + svc := newTestAPIPortalService( + &mockAPIPortalRepository{}, + &mockAPIPortalOrgRepository{result: &model.Organization{}}, + &mockAPIPortalAuditRepository{}, + ) + _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ + Handle: "acme", + Name: "Acme", + AuthType: constants.APIPortalAuthTypeLocal, + URL: "", + }, "org-1", "user-1") + if err != nil { + t.Fatalf("empty URL rejected: %v", err) + } +} + +func TestAPIPortalService_UpdateAPIPortal_InvalidURLRejected(t *testing.T) { + existing := &model.APIPortal{ + ID: "p1", Handle: "acme", OrganizationID: "org-1", + Name: "Acme", WorkflowStatus: constants.APIPortalWorkflowStatusActive, + AuthType: constants.APIPortalAuthTypeLocal, + } + svc := newTestAPIPortalService( + &mockAPIPortalRepository{getResult: existing}, + &mockAPIPortalOrgRepository{}, + &mockAPIPortalAuditRepository{}, + ) + _, err := svc.UpdateAPIPortal("acme", &UpdateAPIPortalRequest{ + URL: apiPortalStrPtr("http://insecure.example.com"), + }, "org-1", "editor") + if err == nil || !apperror.ValidationFailed.Is(err) { + t.Fatalf("want ValidationFailed for http URL on Update, got %v", err) + } +} + // --- Get tests --- func TestAPIPortalService_GetAPIPortal_HappyPath(t *testing.T) { From 0d7ca1dadee9080bf77af1297c4083d250ac8668 Mon Sep 17 00:00:00 2001 From: dushaniw Date: Mon, 17 Aug 2026 12:55:41 +0530 Subject: [PATCH 08/25] Align API Portal description maxLength with database column Reduce the OAS description maxLength from 4000 to 1023 across ApiPortalResponse, ApiPortalListItem, CreateApiPortalRequest, and UpdateApiPortalRequest so the contract matches the VARCHAR(1023) column on all three engines. 1023 is the convention used by every other description column in the platform-api schema. Co-Authored-By: Claude Opus 4.7 (1M context) --- platform-api/resources/openapi.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index b8005b7d0b..6be0a3e914 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -8870,7 +8870,7 @@ components: description: type: string nullable: true - maxLength: 4000 + maxLength: 1023 url: type: string format: uri @@ -8968,7 +8968,7 @@ components: description: type: string nullable: true - maxLength: 4000 + maxLength: 1023 url: type: string format: uri @@ -8995,7 +8995,7 @@ components: description: type: string nullable: true - maxLength: 4000 + maxLength: 1023 url: type: string format: uri From 48e92af64c63e22af63ef3f66981df000bc1d184 Mon Sep 17 00:00:00 2001 From: dushaniw Date: Mon, 17 Aug 2026 15:22:55 +0530 Subject: [PATCH 09/25] Accept workflowStatus on create and enforce url/status consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the CreateApiPortalRequest schema with an optional workflowStatus field (enum: pending, active) so an OSS caller who already knows the portal URL can register it as active in a single call, instead of POST-then-PUT. When omitted, the default remains pending. `failed` is intentionally rejected on create. Add a cross-field rule in the service layer applied on both Create and Update: workflowStatus cannot be active while url is empty. This catches the two mutation shapes that would otherwise land a portal in an unreachable state — setting workflowStatus=active without supplying a URL, and clearing url on a portal whose status is currently active. Handler picks up the new request field and passes it to the service. Service uses a create-only workflow-status whitelist (pending, active) distinct from the full valid set (pending, active, failed) that Update accepts. Tests cover: Create active with URL, Create active without URL rejected, Create failed rejected, Update activate-without-URL rejected, Update clear-URL-while-active rejected, and the provisioner-callback path where a single PUT sets both URL and workflowStatus=active. Co-Authored-By: Claude Opus 4.7 (1M context) --- platform-api/api/generated.go | 18 ++- platform-api/internal/constants/constants.go | 9 ++ platform-api/internal/handler/api_portal.go | 3 + .../handler/api_portal_integration_test.go | 44 +++++++ platform-api/internal/service/api_portal.go | 16 ++- .../internal/service/api_portal_test.go | 123 +++++++++++++++++- platform-api/resources/openapi.yaml | 8 ++ 7 files changed, 214 insertions(+), 7 deletions(-) diff --git a/platform-api/api/generated.go b/platform-api/api/generated.go index ee1c404f16..548b8314dc 100644 --- a/platform-api/api/generated.go +++ b/platform-api/api/generated.go @@ -83,6 +83,12 @@ const ( CreateApiPortalRequestAuthTypeOauth2 CreateApiPortalRequestAuthType = "oauth2" ) +// Defines values for CreateApiPortalRequestWorkflowStatus. +const ( + CreateApiPortalRequestWorkflowStatusActive CreateApiPortalRequestWorkflowStatus = "active" + CreateApiPortalRequestWorkflowStatusPending CreateApiPortalRequestWorkflowStatus = "pending" +) + // Defines values for CreateGatewayRequestFunctionalityType. const ( CreateGatewayRequestFunctionalityTypeAi CreateGatewayRequestFunctionalityType = "ai" @@ -424,9 +430,9 @@ const ( // Defines values for ListApiPortalsParamsWorkflowStatus. const ( - Active ListApiPortalsParamsWorkflowStatus = "active" - Failed ListApiPortalsParamsWorkflowStatus = "failed" - Pending ListApiPortalsParamsWorkflowStatus = "pending" + ListApiPortalsParamsWorkflowStatusActive ListApiPortalsParamsWorkflowStatus = "active" + ListApiPortalsParamsWorkflowStatusFailed ListApiPortalsParamsWorkflowStatus = "failed" + ListApiPortalsParamsWorkflowStatusPending ListApiPortalsParamsWorkflowStatus = "pending" ) // Defines values for ListApplicationsParamsSortBy. @@ -871,11 +877,17 @@ type CreateApiPortalRequest struct { // Url Public URL of an existing API Portal to register. Omit to have a new portal provisioned; the URL will be populated once the instance is reachable. Url *string `json:"url" yaml:"url"` + + // WorkflowStatus Optional. Defaults to `pending` when omitted. Setting `active` requires a non-empty `url`; the request is rejected otherwise. `failed` cannot be set on create — a portal is never created in a failed state. + WorkflowStatus *CreateApiPortalRequestWorkflowStatus `json:"workflowStatus,omitempty" yaml:"workflowStatus,omitempty"` } // CreateApiPortalRequestAuthType defines model for CreateApiPortalRequest.AuthType. type CreateApiPortalRequestAuthType string +// CreateApiPortalRequestWorkflowStatus Optional. Defaults to `pending` when omitted. Setting `active` requires a non-empty `url`; the request is rejected otherwise. `failed` cannot be set on create — a portal is never created in a failed state. +type CreateApiPortalRequestWorkflowStatus string + // CreateApplicationRequest Request body for creating an application. type CreateApplicationRequest struct { // Description Description of the application diff --git a/platform-api/internal/constants/constants.go b/platform-api/internal/constants/constants.go index b8ae64833f..318cdbff11 100644 --- a/platform-api/internal/constants/constants.go +++ b/platform-api/internal/constants/constants.go @@ -221,6 +221,15 @@ var ValidAPIPortalWorkflowStatuses = map[string]bool{ APIPortalWorkflowStatusFailed: true, } +// ValidAPIPortalCreateWorkflowStatuses holds accepted values for workflow_status +// at Create time. `failed` is intentionally excluded — a portal is never +// created in a failed state; that state is only reachable via a subsequent +// update once provisioning or a health check reports failure. +var ValidAPIPortalCreateWorkflowStatuses = map[string]bool{ + APIPortalWorkflowStatusPending: true, + APIPortalWorkflowStatusActive: true, +} + // API Portal auth type constants const ( APIPortalAuthTypeLocal = "local" diff --git a/platform-api/internal/handler/api_portal.go b/platform-api/internal/handler/api_portal.go index 7acaf25950..699a13fdac 100644 --- a/platform-api/internal/handler/api_portal.go +++ b/platform-api/internal/handler/api_portal.go @@ -74,6 +74,9 @@ func (h *APIPortalHandler) CreateAPIPortal(w http.ResponseWriter, r *http.Reques AuthType: string(req.AuthType), Configuration: derefConfig(req.Config), } + if req.WorkflowStatus != nil { + svcReq.WorkflowStatus = string(*req.WorkflowStatus) + } portal, err := h.svc.CreateAPIPortal(svcReq, orgID, createdBy) if err != nil { return serviceError(err, fmt.Sprintf("failed to create api portal %q for org %s by user %s", svcReq.Handle, orgID, createdBy)) diff --git a/platform-api/internal/handler/api_portal_integration_test.go b/platform-api/internal/handler/api_portal_integration_test.go index a57ff607ae..8bc88726e9 100644 --- a/platform-api/internal/handler/api_portal_integration_test.go +++ b/platform-api/internal/handler/api_portal_integration_test.go @@ -187,6 +187,50 @@ func TestAPIPortalHandler_Create_MissingName(t *testing.T) { } } +func TestAPIPortalHandler_Create_WithActiveStatus(t *testing.T) { + r, _, cleanup := setupAPIPortalHandlerEnv(t) + t.Cleanup(cleanup) + + body := mustJSON(t, map[string]any{ + "name": "Acme Portal", + "handle": "acme-active", + "authType": "local", + "url": "https://acme.example.com", + "workflowStatus": "active", + }) + req := apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("Create: want 201, got %d: %s", rec.Code, rec.Body.String()) + } + var got apiPortalResp + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.WorkflowStatus != "active" { + t.Errorf("want active, got %q", got.WorkflowStatus) + } +} + +func TestAPIPortalHandler_Create_ActiveWithoutURL(t *testing.T) { + r, _, cleanup := setupAPIPortalHandlerEnv(t) + t.Cleanup(cleanup) + + body := mustJSON(t, map[string]any{ + "name": "Acme Portal", + "handle": "acme-bad", + "authType": "local", + "workflowStatus": "active", + }) + req := apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("Create: want 400 for active without url, got %d: %s", rec.Code, rec.Body.String()) + } +} + func TestAPIPortalHandler_Create_HandleConflict(t *testing.T) { r, _, cleanup := setupAPIPortalHandlerEnv(t) t.Cleanup(cleanup) diff --git a/platform-api/internal/service/api_portal.go b/platform-api/internal/service/api_portal.go index 1db1a21fa1..1df322c6a1 100644 --- a/platform-api/internal/service/api_portal.go +++ b/platform-api/internal/service/api_portal.go @@ -160,14 +160,18 @@ func (s *APIPortalService) CreateAPIPortal(req *CreateAPIPortalRequest, orgID, c workflowStatus := strings.TrimSpace(req.WorkflowStatus) if workflowStatus == "" { workflowStatus = constants.APIPortalWorkflowStatusPending - } else if !constants.ValidAPIPortalWorkflowStatuses[workflowStatus] { + } else if !constants.ValidAPIPortalCreateWorkflowStatuses[workflowStatus] { return nil, apperror.ValidationFailed.New( - fmt.Sprintf("The workflowStatus %q is not supported.", workflowStatus)) + fmt.Sprintf("The workflowStatus %q is not supported on create.", workflowStatus)) } portalURL, err := validateAPIPortalURL(req.URL) if err != nil { return nil, err } + if workflowStatus == constants.APIPortalWorkflowStatusActive && portalURL == "" { + return nil, apperror.ValidationFailed.New( + "The workflowStatus cannot be active when url is empty.") + } org, err := s.orgRepo.GetOrganizationByUUID(orgID) if err != nil { @@ -319,6 +323,14 @@ func (s *APIPortalService) UpdateAPIPortal(handle string, req *UpdateAPIPortalRe if req.Configuration != nil { portal.Configuration = req.Configuration } + // After applying all whitelisted mutations, enforce the cross-field rule: + // a portal cannot be in the active state without a URL. This catches both + // "set workflowStatus=active while url is empty" and "clear url while + // status is currently active". + if portal.WorkflowStatus == constants.APIPortalWorkflowStatusActive && portal.URL == "" { + return nil, apperror.ValidationFailed.New( + "The workflowStatus cannot be active when url is empty.") + } portal.UpdatedBy = strings.TrimSpace(updatedBy) if err := s.portalRepo.Update(portal); err != nil { diff --git a/platform-api/internal/service/api_portal_test.go b/platform-api/internal/service/api_portal_test.go index 8e72d6e2ac..38530b6c1f 100644 --- a/platform-api/internal/service/api_portal_test.go +++ b/platform-api/internal/service/api_portal_test.go @@ -338,6 +338,124 @@ func TestAPIPortalService_CreateAPIPortal_EmptyURLAllowed(t *testing.T) { } } +func TestAPIPortalService_CreateAPIPortal_ActiveWithURL(t *testing.T) { + svc := newTestAPIPortalService( + &mockAPIPortalRepository{}, + &mockAPIPortalOrgRepository{result: &model.Organization{}}, + &mockAPIPortalAuditRepository{}, + ) + got, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ + Handle: "acme", Name: "Acme", AuthType: constants.APIPortalAuthTypeLocal, + URL: "https://acme.example.com", + WorkflowStatus: constants.APIPortalWorkflowStatusActive, + }, "org-1", "user-1") + if err != nil { + t.Fatalf("CreateAPIPortal: %v", err) + } + if got.WorkflowStatus != constants.APIPortalWorkflowStatusActive { + t.Errorf("want active, got %q", got.WorkflowStatus) + } +} + +func TestAPIPortalService_CreateAPIPortal_ActiveWithoutURLRejected(t *testing.T) { + svc := newTestAPIPortalService( + &mockAPIPortalRepository{}, + &mockAPIPortalOrgRepository{result: &model.Organization{}}, + &mockAPIPortalAuditRepository{}, + ) + _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ + Handle: "acme", Name: "Acme", AuthType: constants.APIPortalAuthTypeLocal, + URL: "", + WorkflowStatus: constants.APIPortalWorkflowStatusActive, + }, "org-1", "user-1") + if err == nil || !apperror.ValidationFailed.Is(err) { + t.Fatalf("want ValidationFailed for active + empty url, got %v", err) + } +} + +func TestAPIPortalService_CreateAPIPortal_FailedStatusRejected(t *testing.T) { + svc := newTestAPIPortalService( + &mockAPIPortalRepository{}, + &mockAPIPortalOrgRepository{result: &model.Organization{}}, + &mockAPIPortalAuditRepository{}, + ) + _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ + Handle: "acme", Name: "Acme", AuthType: constants.APIPortalAuthTypeLocal, + URL: "https://acme.example.com", + WorkflowStatus: constants.APIPortalWorkflowStatusFailed, + }, "org-1", "user-1") + if err == nil || !apperror.ValidationFailed.Is(err) { + t.Fatalf("want ValidationFailed for failed status on create, got %v", err) + } +} + +func TestAPIPortalService_UpdateAPIPortal_ActivateWithoutURLRejected(t *testing.T) { + // Existing row has no URL; caller tries to flip status to active. + existing := &model.APIPortal{ + ID: "p1", Handle: "acme", OrganizationID: "org-1", + Name: "Acme", URL: "", + WorkflowStatus: constants.APIPortalWorkflowStatusPending, + AuthType: constants.APIPortalAuthTypeLocal, + } + svc := newTestAPIPortalService( + &mockAPIPortalRepository{getResult: existing}, + &mockAPIPortalOrgRepository{}, + &mockAPIPortalAuditRepository{}, + ) + _, err := svc.UpdateAPIPortal("acme", &UpdateAPIPortalRequest{ + WorkflowStatus: apiPortalStrPtr(constants.APIPortalWorkflowStatusActive), + }, "org-1", "editor") + if err == nil || !apperror.ValidationFailed.Is(err) { + t.Fatalf("want ValidationFailed activating without URL, got %v", err) + } +} + +func TestAPIPortalService_UpdateAPIPortal_ClearURLWhileActiveRejected(t *testing.T) { + // Existing row is active with a URL; caller tries to clear the URL. + existing := &model.APIPortal{ + ID: "p1", Handle: "acme", OrganizationID: "org-1", + Name: "Acme", URL: "https://acme.example.com", + WorkflowStatus: constants.APIPortalWorkflowStatusActive, + AuthType: constants.APIPortalAuthTypeLocal, + } + svc := newTestAPIPortalService( + &mockAPIPortalRepository{getResult: existing}, + &mockAPIPortalOrgRepository{}, + &mockAPIPortalAuditRepository{}, + ) + _, err := svc.UpdateAPIPortal("acme", &UpdateAPIPortalRequest{ + URL: apiPortalStrPtr(""), + }, "org-1", "editor") + if err == nil || !apperror.ValidationFailed.Is(err) { + t.Fatalf("want ValidationFailed clearing URL while active, got %v", err) + } +} + +func TestAPIPortalService_UpdateAPIPortal_ActivateWithNewURL(t *testing.T) { + // Provisioner-callback scenario: single PUT sets both URL and status. + existing := &model.APIPortal{ + ID: "p1", Handle: "acme", OrganizationID: "org-1", + Name: "Acme", URL: "", + WorkflowStatus: constants.APIPortalWorkflowStatusPending, + AuthType: constants.APIPortalAuthTypeLocal, + } + svc := newTestAPIPortalService( + &mockAPIPortalRepository{getResult: existing}, + &mockAPIPortalOrgRepository{}, + &mockAPIPortalAuditRepository{}, + ) + got, err := svc.UpdateAPIPortal("acme", &UpdateAPIPortalRequest{ + URL: apiPortalStrPtr("https://acme.example.com"), + WorkflowStatus: apiPortalStrPtr(constants.APIPortalWorkflowStatusActive), + }, "org-1", "editor") + if err != nil { + t.Fatalf("UpdateAPIPortal: %v", err) + } + if got.URL != "https://acme.example.com" || got.WorkflowStatus != constants.APIPortalWorkflowStatusActive { + t.Errorf("provisioner-callback path did not apply both fields: %+v", got) + } +} + func TestAPIPortalService_UpdateAPIPortal_InvalidURLRejected(t *testing.T) { existing := &model.APIPortal{ ID: "p1", Handle: "acme", OrganizationID: "org-1", @@ -443,8 +561,9 @@ func TestAPIPortalService_ListAPIPortals_InvalidWorkflowStatus(t *testing.T) { func TestAPIPortalService_UpdateAPIPortal_HappyPath(t *testing.T) { existing := &model.APIPortal{ ID: "p1", Handle: "acme", OrganizationID: "org-1", - Name: "old", WorkflowStatus: constants.APIPortalWorkflowStatusPending, - AuthType: constants.APIPortalAuthTypeLocal, + Name: "old", URL: "https://acme.example.com", + WorkflowStatus: constants.APIPortalWorkflowStatusPending, + AuthType: constants.APIPortalAuthTypeLocal, } portalRepo := &mockAPIPortalRepository{getResult: existing} auditRepo := &mockAPIPortalAuditRepository{} diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index 6be0a3e914..ac74e9f46f 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -8980,6 +8980,14 @@ components: authType: type: string enum: [local, oauth2] + workflowStatus: + type: string + enum: [pending, active] + description: >- + Optional. Defaults to `pending` when omitted. Setting `active` + requires a non-empty `url`; the request is rejected otherwise. + `failed` cannot be set on create — a portal is never created in a + failed state. config: $ref: '#/components/schemas/ApiPortalConfig' From 11211d8adb0ba174cb924356884ee397c8e092d8 Mon Sep 17 00:00:00 2001 From: dushaniw Date: Mon, 17 Aug 2026 17:46:30 +0530 Subject: [PATCH 10/25] Split API Portal config into authConfig and metadata; encrypt secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single `configuration BYTEA` column with two columns — `auth_configuration` (Platform-API's outbound auth material) and `metadata` (pass-through data for the portal pod) — across all three engine schema files. Wire shape mirrors: the OAS drops the opaque `config` field and introduces `authConfig` (typed, additionalProperties false) and `metadata` (open pass-through) on request and response schemas. `authConfig.clientSecret` is `writeOnly` in the OAS. On write the service encrypts values for keys in APIPortalAuthConfigSensitiveKeys via the existing platform vault (AES-256-GCM) and base64-stores the ciphertext in the JSON blob. On read the handler strips those same keys from the response so the secret never appears on the wire — belt-and-suspenders alongside the `writeOnly` marker. Per-authType validation: - `local` → authConfig must be empty. - `oauth2` → stsTokenUrl, clientId, clientSecret are all required and only those three keys are accepted. Update uses merge semantics on authConfig so a caller can rotate a single field without re-supplying the stored clientSecret (which they can't fetch back). Metadata uses replace semantics — supplied map fully replaces stored. Tests updated across repo/service/handler layers. New handler test `Create_OAuth2_EncryptsClientSecret` explicitly verifies clientSecret is absent from POST response bodies AND from the persisted DB blob. Co-Authored-By: Claude Opus 4.7 (1M context) --- platform-api/api/generated.go | 86 +++++++--- platform-api/internal/constants/constants.go | 26 +++ .../internal/database/schema.postgres.sql | 13 +- .../internal/database/schema.sqlite.sql | 13 +- .../internal/database/schema.sqlserver.sql | 13 +- platform-api/internal/handler/api_portal.go | 99 +++++++++-- .../handler/api_portal_integration_test.go | 80 ++++++++- platform-api/internal/model/api_portal.go | 15 +- .../internal/repository/api_portal.go | 78 ++++++--- .../internal/repository/api_portal_test.go | 40 ++--- platform-api/internal/server/server.go | 2 +- platform-api/internal/service/api_portal.go | 158 +++++++++++++++++- .../internal/service/api_portal_test.go | 103 +++++++----- platform-api/resources/openapi.yaml | 57 +++++-- 14 files changed, 606 insertions(+), 177 deletions(-) diff --git a/platform-api/api/generated.go b/platform-api/api/generated.go index 548b8314dc..f580f2b511 100644 --- a/platform-api/api/generated.go +++ b/platform-api/api/generated.go @@ -623,10 +623,23 @@ type AddGatewayToRESTAPIRequest struct { GatewayId string `binding:"required" json:"gatewayId" yaml:"gatewayId"` } -// ApiPortalConfig Configuration for how Platform API authenticates to the portal's admin -// API. Shape depends on `authType`; treated as an opaque object at the -// wire level. -type ApiPortalConfig map[string]interface{} +// ApiPortalAuthConfig Platform-API's outbound authentication material for the portal admin +// API. Shape depends on `authType`: +// - `local` → must be empty. +// - `oauth2` → `stsTokenUrl`, `clientId`, `clientSecret` are all required. +// +// `clientSecret` is write-only: accepted on create/update requests, persisted +// encrypted at rest, and never returned on read. +type ApiPortalAuthConfig struct { + // ClientId Registered client identifier in the STS. + ClientId *string `json:"clientId,omitempty" yaml:"clientId,omitempty"` + + // ClientSecret Registered client secret. Accepted only in create/update requests; never returned in responses. Persisted encrypted server-side. + ClientSecret *string `json:"clientSecret,omitempty" yaml:"clientSecret,omitempty"` + + // StsTokenUrl Token endpoint of the STS Platform-API POSTs the client_credentials grant to. + StsTokenUrl *string `json:"stsTokenUrl,omitempty" yaml:"stsTokenUrl,omitempty"` +} // ApiPortalListItem Lightweight projection returned in collection responses (excludes the `config` blob). type ApiPortalListItem struct { @@ -654,17 +667,23 @@ type ApiPortalListResponse struct { Pagination Pagination `json:"pagination" yaml:"pagination"` } +// ApiPortalMetadata Free-form pass-through metadata for the portal pod (e.g. cloud-side OIDC endpoints the portal uses for consumer login). Platform-API stores and returns this as-is; it is not consumed by the outbound authentication path. +type ApiPortalMetadata map[string]interface{} + // ApiPortalResponse defines model for ApiPortalResponse. type ApiPortalResponse struct { - // AuthType Determines how Platform API authenticates to the portal's admin API and selects the shape of the `config` object. - AuthType ApiPortalResponseAuthType `binding:"required" json:"authType" yaml:"authType"` + // AuthConfig Platform-API's outbound authentication material for the portal admin + // API. Shape depends on `authType`: + // - `local` → must be empty. + // - `oauth2` → `stsTokenUrl`, `clientId`, `clientSecret` are all required. + // `clientSecret` is write-only: accepted on create/update requests, persisted + // encrypted at rest, and never returned on read. + AuthConfig *ApiPortalAuthConfig `json:"authConfig,omitempty" yaml:"authConfig,omitempty"` - // Config Configuration for how Platform API authenticates to the portal's admin - // API. Shape depends on `authType`; treated as an opaque object at the - // wire level. - Config *ApiPortalConfig `json:"config,omitempty" yaml:"config,omitempty"` - CreatedAt *time.Time `binding:"required" json:"createdAt,omitempty" yaml:"createdAt,omitempty"` - Description *string `json:"description" yaml:"description"` + // AuthType Determines how Platform API authenticates to the portal's admin API and selects the shape of the `config` object. + AuthType ApiPortalResponseAuthType `binding:"required" json:"authType" yaml:"authType"` + CreatedAt *time.Time `binding:"required" json:"createdAt,omitempty" yaml:"createdAt,omitempty"` + Description *string `json:"description" yaml:"description"` // Handle URL-friendly slug. Immutable after creation. Equal to `id`. Handle *string `binding:"required" json:"handle,omitempty" yaml:"handle,omitempty"` @@ -672,6 +691,9 @@ type ApiPortalResponse struct { // Id Handle (URL-friendly slug) of the API Portal — primary identifier. Id *string `binding:"required" json:"id,omitempty" yaml:"id,omitempty"` + // Metadata Free-form pass-through metadata for the portal pod (e.g. cloud-side OIDC endpoints the portal uses for consumer login). Platform-API stores and returns this as-is; it is not consumed by the outbound authentication path. + Metadata *ApiPortalMetadata `json:"metadata,omitempty" yaml:"metadata,omitempty"` + // Name Display name. Name string `binding:"required" json:"name" yaml:"name"` UpdatedAt *time.Time `binding:"required" json:"updatedAt,omitempty" yaml:"updatedAt,omitempty"` @@ -863,17 +885,22 @@ type CreateAPIKeyResponseStatus string // CreateApiPortalRequest defines model for CreateApiPortalRequest. type CreateApiPortalRequest struct { - AuthType CreateApiPortalRequestAuthType `binding:"required" json:"authType" yaml:"authType"` - - // Config Configuration for how Platform API authenticates to the portal's admin - // API. Shape depends on `authType`; treated as an opaque object at the - // wire level. - Config *ApiPortalConfig `json:"config,omitempty" yaml:"config,omitempty"` - Description *string `json:"description" yaml:"description"` + // AuthConfig Platform-API's outbound authentication material for the portal admin + // API. Shape depends on `authType`: + // - `local` → must be empty. + // - `oauth2` → `stsTokenUrl`, `clientId`, `clientSecret` are all required. + // `clientSecret` is write-only: accepted on create/update requests, persisted + // encrypted at rest, and never returned on read. + AuthConfig *ApiPortalAuthConfig `json:"authConfig,omitempty" yaml:"authConfig,omitempty"` + AuthType CreateApiPortalRequestAuthType `binding:"required" json:"authType" yaml:"authType"` + Description *string `json:"description" yaml:"description"` // Handle URL-friendly slug. Must be unique within the org. Immutable after creation. Handle string `binding:"required" json:"handle" yaml:"handle"` - Name string `binding:"required" json:"name" yaml:"name"` + + // Metadata Free-form pass-through metadata for the portal pod (e.g. cloud-side OIDC endpoints the portal uses for consumer login). Platform-API stores and returns this as-is; it is not consumed by the outbound authentication path. + Metadata *ApiPortalMetadata `json:"metadata,omitempty" yaml:"metadata,omitempty"` + Name string `binding:"required" json:"name" yaml:"name"` // Url Public URL of an existing API Portal to register. Omit to have a new portal provisioned; the URL will be populated once the instance is reachable. Url *string `json:"url" yaml:"url"` @@ -2721,13 +2748,18 @@ type UpdateAPIKeyResponseStatus string // UpdateApiPortalRequest All fields optional. Only mutable fields are accepted — see field permissions in the design doc. type UpdateApiPortalRequest struct { - AuthType *UpdateApiPortalRequestAuthType `json:"authType,omitempty" yaml:"authType,omitempty"` - - // Config Configuration for how Platform API authenticates to the portal's admin - // API. Shape depends on `authType`; treated as an opaque object at the - // wire level. - Config *ApiPortalConfig `json:"config,omitempty" yaml:"config,omitempty"` - Description *string `json:"description" yaml:"description"` + // AuthConfig Platform-API's outbound authentication material for the portal admin + // API. Shape depends on `authType`: + // - `local` → must be empty. + // - `oauth2` → `stsTokenUrl`, `clientId`, `clientSecret` are all required. + // `clientSecret` is write-only: accepted on create/update requests, persisted + // encrypted at rest, and never returned on read. + AuthConfig *ApiPortalAuthConfig `json:"authConfig,omitempty" yaml:"authConfig,omitempty"` + AuthType *UpdateApiPortalRequestAuthType `json:"authType,omitempty" yaml:"authType,omitempty"` + Description *string `json:"description" yaml:"description"` + + // Metadata Free-form pass-through metadata for the portal pod (e.g. cloud-side OIDC endpoints the portal uses for consumer login). Platform-API stores and returns this as-is; it is not consumed by the outbound authentication path. + Metadata *ApiPortalMetadata `json:"metadata,omitempty" yaml:"metadata,omitempty"` Name *string `json:"name,omitempty" yaml:"name,omitempty"` Url *string `json:"url" yaml:"url"` WorkflowStatus *UpdateApiPortalRequestWorkflowStatus `json:"workflowStatus,omitempty" yaml:"workflowStatus,omitempty"` diff --git a/platform-api/internal/constants/constants.go b/platform-api/internal/constants/constants.go index 318cdbff11..3f03367c40 100644 --- a/platform-api/internal/constants/constants.go +++ b/platform-api/internal/constants/constants.go @@ -230,6 +230,32 @@ var ValidAPIPortalCreateWorkflowStatuses = map[string]bool{ APIPortalWorkflowStatusActive: true, } +// API Portal authConfig field-name constants used by Create/Update validation +// (required-field check) and by ClientCredentialsAuthProvider (payload build). +const ( + APIPortalAuthConfigKeySTSTokenURL = "stsTokenUrl" + APIPortalAuthConfigKeyClientID = "clientId" + APIPortalAuthConfigKeyClientSecret = "clientSecret" +) + +// APIPortalOAuth2RequiredAuthConfigKeys are the keys the oauth2 flow must +// supply in authConfig at Create time (or on Update when auth_type is being +// changed to oauth2). Order is stable so validation error messages list +// missing fields in a predictable sequence. +var APIPortalOAuth2RequiredAuthConfigKeys = []string{ + APIPortalAuthConfigKeySTSTokenURL, + APIPortalAuthConfigKeyClientID, + APIPortalAuthConfigKeyClientSecret, +} + +// APIPortalAuthConfigSensitiveKeys lists the authConfig keys whose values are +// treated as secrets: encrypted at rest via the platform vault and stripped +// from any response. Independent of auth_type — the set is small and the +// keys are the same shape across types. +var APIPortalAuthConfigSensitiveKeys = []string{ + APIPortalAuthConfigKeyClientSecret, +} + // API Portal auth type constants const ( APIPortalAuthTypeLocal = "local" diff --git a/platform-api/internal/database/schema.postgres.sql b/platform-api/internal/database/schema.postgres.sql index ac041d99ab..2a7b2eeb0f 100644 --- a/platform-api/internal/database/schema.postgres.sql +++ b/platform-api/internal/database/schema.postgres.sql @@ -408,12 +408,13 @@ CREATE TABLE IF NOT EXISTS api_portals ( description VARCHAR(1023), url VARCHAR(500), workflow_status VARCHAR(20) NOT NULL DEFAULT 'pending', - auth_type VARCHAR(20) NOT NULL, - configuration BYTEA NOT NULL, - created_by VARCHAR(200), - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_by VARCHAR(200), - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + auth_type VARCHAR(20) NOT NULL, + auth_configuration BYTEA NOT NULL, + metadata BYTEA NOT NULL, + created_by VARCHAR(200), + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_by VARCHAR(200), + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, UNIQUE (organization_uuid, handle) ); diff --git a/platform-api/internal/database/schema.sqlite.sql b/platform-api/internal/database/schema.sqlite.sql index 652377867b..e29787de79 100644 --- a/platform-api/internal/database/schema.sqlite.sql +++ b/platform-api/internal/database/schema.sqlite.sql @@ -408,12 +408,13 @@ CREATE TABLE IF NOT EXISTS api_portals ( description VARCHAR(1023), url VARCHAR(500), workflow_status VARCHAR(20) NOT NULL DEFAULT 'pending', - auth_type VARCHAR(20) NOT NULL, - configuration BLOB NOT NULL, - created_by VARCHAR(200), - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_by VARCHAR(200), - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + auth_type VARCHAR(20) NOT NULL, + auth_configuration BLOB NOT NULL, + metadata BLOB NOT NULL, + created_by VARCHAR(200), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_by VARCHAR(200), + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, UNIQUE (organization_uuid, handle) ); diff --git a/platform-api/internal/database/schema.sqlserver.sql b/platform-api/internal/database/schema.sqlserver.sql index 410a7a154c..b635256289 100644 --- a/platform-api/internal/database/schema.sqlserver.sql +++ b/platform-api/internal/database/schema.sqlserver.sql @@ -460,12 +460,13 @@ CREATE TABLE dbo.api_portals ( description VARCHAR(1023), url VARCHAR(500), workflow_status VARCHAR(20) NOT NULL DEFAULT 'pending', - auth_type VARCHAR(20) NOT NULL, - configuration VARBINARY(MAX) NOT NULL, - created_by VARCHAR(200), - created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), - updated_by VARCHAR(200), - updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), + auth_type VARCHAR(20) NOT NULL, + auth_configuration VARBINARY(MAX) NOT NULL, + metadata VARBINARY(MAX) NOT NULL, + created_by VARCHAR(200), + created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), + updated_by VARCHAR(200), + updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, UNIQUE (organization_uuid, handle) ); diff --git a/platform-api/internal/handler/api_portal.go b/platform-api/internal/handler/api_portal.go index 699a13fdac..78b796bead 100644 --- a/platform-api/internal/handler/api_portal.go +++ b/platform-api/internal/handler/api_portal.go @@ -67,12 +67,13 @@ func (h *APIPortalHandler) CreateAPIPortal(w http.ResponseWriter, r *http.Reques } svcReq := &service.CreateAPIPortalRequest{ - Handle: strings.TrimSpace(req.Handle), - Name: strings.TrimSpace(req.Name), - Description: deref(req.Description), - URL: deref(req.Url), - AuthType: string(req.AuthType), - Configuration: derefConfig(req.Config), + Handle: strings.TrimSpace(req.Handle), + Name: strings.TrimSpace(req.Name), + Description: deref(req.Description), + URL: deref(req.Url), + AuthType: string(req.AuthType), + AuthConfig: authConfigStructToMap(req.AuthConfig), + Metadata: derefMetadata(req.Metadata), } if req.WorkflowStatus != nil { svcReq.WorkflowStatus = string(*req.WorkflowStatus) @@ -150,10 +151,11 @@ func (h *APIPortalHandler) UpdateAPIPortal(w http.ResponseWriter, r *http.Reques } svcReq := &service.UpdateAPIPortalRequest{ - Name: req.Name, - Description: req.Description, - URL: req.Url, - Configuration: derefConfig(req.Config), + Name: req.Name, + Description: req.Description, + URL: req.Url, + AuthConfig: authConfigStructToMap(req.AuthConfig), + Metadata: derefMetadata(req.Metadata), } if req.WorkflowStatus != nil { v := string(*req.WorkflowStatus) @@ -215,11 +217,75 @@ func deref(p *string) string { return *p } -func derefConfig(c *api.ApiPortalConfig) map[string]interface{} { +// derefMetadata converts the generated Metadata type (a map alias) into a plain +// map[string]interface{} for the service layer, dropping the nil pointer. +func derefMetadata(m *api.ApiPortalMetadata) map[string]interface{} { + if m == nil { + return nil + } + return map[string]interface{}(*m) +} + +// authConfigStructToMap flattens the generated ApiPortalAuthConfig struct into +// the map shape the service layer expects. Nil pointer fields are dropped so +// downstream validation sees "missing" (rather than "present but empty"). +func authConfigStructToMap(c *api.ApiPortalAuthConfig) map[string]interface{} { if c == nil { return nil } - return map[string]interface{}(*c) + out := map[string]interface{}{} + if c.StsTokenUrl != nil { + out[constants.APIPortalAuthConfigKeySTSTokenURL] = *c.StsTokenUrl + } + if c.ClientId != nil { + out[constants.APIPortalAuthConfigKeyClientID] = *c.ClientId + } + if c.ClientSecret != nil { + out[constants.APIPortalAuthConfigKeyClientSecret] = *c.ClientSecret + } + return out +} + +// stripSensitiveAuthConfig deletes any keys that carry secret material before +// the config leaves the server. Belt-and-suspenders alongside the OAS +// `writeOnly: true` marker on ClientSecret — even if a client somehow round- +// trips a plaintext secret through storage (e.g. during migration or if the +// storage-encrypt step is ever skipped), the response strip guarantees it +// never appears on the wire. +func stripSensitiveAuthConfig(cfg map[string]interface{}) map[string]interface{} { + if cfg == nil { + return nil + } + out := make(map[string]interface{}, len(cfg)) + for k, v := range cfg { + out[k] = v + } + for _, key := range constants.APIPortalAuthConfigSensitiveKeys { + delete(out, key) + } + return out +} + +// mapToAuthConfigStruct rebuilds the generated struct from the stored map for +// response serialization. Sensitive keys are stripped first, so the generated +// ClientSecret pointer stays nil (and — since it's marked omitempty — won't +// appear in the JSON output). +func mapToAuthConfigStruct(m map[string]interface{}) *api.ApiPortalAuthConfig { + stripped := stripSensitiveAuthConfig(m) + if stripped == nil { + return nil + } + c := &api.ApiPortalAuthConfig{} + if v, ok := stripped[constants.APIPortalAuthConfigKeySTSTokenURL].(string); ok && v != "" { + s := v + c.StsTokenUrl = &s + } + if v, ok := stripped[constants.APIPortalAuthConfigKeyClientID].(string); ok && v != "" { + s := v + c.ClientId = &s + } + // ClientSecret is intentionally never populated on the response side. + return c } func modelToAPIPortalResponse(p *model.APIPortal) *api.ApiPortalResponse { @@ -248,9 +314,12 @@ func modelToAPIPortalResponse(p *model.APIPortal) *api.ApiPortalResponse { url := p.URL resp.Url = &url } - if p.Configuration != nil { - cfg := api.ApiPortalConfig(p.Configuration) - resp.Config = &cfg + if p.AuthConfig != nil { + resp.AuthConfig = mapToAuthConfigStruct(p.AuthConfig) + } + if p.Metadata != nil { + m := api.ApiPortalMetadata(p.Metadata) + resp.Metadata = &m } return resp } diff --git a/platform-api/internal/handler/api_portal_integration_test.go b/platform-api/internal/handler/api_portal_integration_test.go index 8bc88726e9..46c2865862 100644 --- a/platform-api/internal/handler/api_portal_integration_test.go +++ b/platform-api/internal/handler/api_portal_integration_test.go @@ -36,10 +36,21 @@ import ( "github.com/wso2/api-platform/platform-api/internal/middleware" "github.com/wso2/api-platform/platform-api/internal/repository" "github.com/wso2/api-platform/platform-api/internal/service" + "github.com/wso2/api-platform/platform-api/internal/vault" _ "github.com/mattn/go-sqlite3" ) +// apiPortalTestVault returns a deterministic in-house vault for integration tests. +func apiPortalTestVault(t *testing.T) vault.SecretVault { + t.Helper() + v, err := vault.NewInHouseVault(bytes.Repeat([]byte("t"), 32)) + if err != nil { + t.Fatalf("test vault: %v", err) + } + return v +} + const apiPortalTestBase = "/api/v0.9/api-portals" const apiPortalTestOrg = "org-portal-it" const apiPortalTestUser = "sub-portal-tester" @@ -74,7 +85,7 @@ func setupAPIPortalHandlerEnv(t *testing.T) (http.Handler, *database.DB, func()) portalRepo := repository.NewAPIPortalRepo(db) orgRepo := repository.NewOrganizationRepo(db) identityService := service.NewIdentityService(repository.NewUserIdentityMappingRepo(db)) - svc := service.NewAPIPortalService(portalRepo, orgRepo, noopAudit{}, identityService, slog.Default()) + svc := service.NewAPIPortalService(portalRepo, orgRepo, noopAudit{}, apiPortalTestVault(t), identityService, slog.Default()) h := NewAPIPortalHandler(svc, identityService, slog.Default()) mux := http.NewServeMux() @@ -116,7 +127,8 @@ type apiPortalResp struct { Url *string `json:"url,omitempty"` WorkflowStatus string `json:"workflowStatus"` AuthType string `json:"authType"` - Config map[string]interface{} `json:"config,omitempty"` + AuthConfig map[string]interface{} `json:"authConfig,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` } type apiPortalListResp struct { @@ -140,11 +152,12 @@ func TestAPIPortalHandler_Create_HappyPath(t *testing.T) { r, _, cleanup := setupAPIPortalHandlerEnv(t) t.Cleanup(cleanup) + // local auth type must have empty authConfig; use metadata for round-trip check. body := mustJSON(t, map[string]any{ "name": "Acme Portal", "handle": "acme", "authType": "local", - "config": map[string]any{"foo": "bar"}, + "metadata": map[string]any{"stsIssuer": "https://sts.example.com"}, }) req := apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body) rec := httptest.NewRecorder() @@ -165,8 +178,53 @@ func TestAPIPortalHandler_Create_HappyPath(t *testing.T) { got.AuthType != "local" || got.WorkflowStatus != "pending" { t.Errorf("response fields wrong: %+v", got) } - if got.Config["foo"] != "bar" { - t.Errorf("config round-trip failed: %v", got.Config) + if got.Metadata["stsIssuer"] != "https://sts.example.com" { + t.Errorf("metadata round-trip failed: %v", got.Metadata) + } +} + +func TestAPIPortalHandler_Create_OAuth2_EncryptsClientSecret(t *testing.T) { + r, db, cleanup := setupAPIPortalHandlerEnv(t) + t.Cleanup(cleanup) + + // oauth2 authConfig with a plaintext clientSecret. + body := mustJSON(t, map[string]any{ + "name": "Acme OAuth", + "handle": "acme-oauth", + "authType": "oauth2", + "url": "https://acme.example.com", + "authConfig": map[string]any{ + "stsTokenUrl": "https://sts.example.com/token", + "clientId": "abc", + "clientSecret": "s3cr3t-plaintext", + }, + }) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body)) + if rec.Code != http.StatusCreated { + t.Fatalf("Create: want 201, got %d: %s", rec.Code, rec.Body.String()) + } + + // Response must NOT include clientSecret; other authConfig fields visible. + var got apiPortalResp + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.AuthConfig["stsTokenUrl"] != "https://sts.example.com/token" || + got.AuthConfig["clientId"] != "abc" { + t.Errorf("non-secret authConfig fields missing in response: %+v", got.AuthConfig) + } + if _, present := got.AuthConfig["clientSecret"]; present { + t.Errorf("clientSecret leaked in response body: %v", got.AuthConfig) + } + + // DB must NOT contain plaintext secret. + var authCfgBlob []byte + if err := db.QueryRow(`SELECT auth_configuration FROM api_portals WHERE handle = 'acme-oauth'`).Scan(&authCfgBlob); err != nil { + t.Fatalf("query auth_configuration: %v", err) + } + if strings.Contains(string(authCfgBlob), "s3cr3t-plaintext") { + t.Errorf("plaintext clientSecret found in DB blob: %s", authCfgBlob) } } @@ -401,8 +459,16 @@ func TestAPIPortalHandler_Update_HappyPath(t *testing.T) { t.Fatalf("seed: %d %s", rec.Code, rec.Body.String()) } - // Update name + authType. - patch := mustJSON(t, map[string]any{"name": "new", "authType": "oauth2"}) + // Update name + authType — swapping to oauth2 requires supplying a full authConfig. + patch := mustJSON(t, map[string]any{ + "name": "new", + "authType": "oauth2", + "authConfig": map[string]any{ + "stsTokenUrl": "https://sts.example.com/token", + "clientId": "abc", + "clientSecret": "s3cr3t", + }, + }) putRec := httptest.NewRecorder() r.ServeHTTP(putRec, apiPortalTestRequest(t, http.MethodPut, apiPortalTestBase+"/acme", patch)) if putRec.Code != http.StatusOK { diff --git a/platform-api/internal/model/api_portal.go b/platform-api/internal/model/api_portal.go index cbc24f151e..20df6a848a 100644 --- a/platform-api/internal/model/api_portal.go +++ b/platform-api/internal/model/api_portal.go @@ -24,9 +24,15 @@ import ( ) // APIPortal represents an API Portal registered within an organization. -// The Configuration blob carries auth-type-specific fields: -// - auth_type=local : may hold key material references (local JWT minting). -// - auth_type=oauth2 : holds STS token URL, client credentials, optional audience. +// +// Two persisted blobs, split by consumer: +// - AuthConfig is consumed by Platform-API's outbound AuthProvider path. +// Shape depends on auth_type: `local` = empty; `oauth2` = stsTokenUrl, +// clientId, clientSecret. Sensitive values (clientSecret) are stored +// encrypted; the plaintext key is never returned in responses. +// - Metadata is opaque pass-through data (never encrypted, always returned). +// Typically carries the cloud-side OIDC endpoints that the portal pod uses +// for consumer login (stsIssuer, stsJwksUrl, etc.); usually empty in OSS. type APIPortal struct { ID string `json:"id" db:"uuid"` OrganizationID string `json:"organizationId" db:"organization_uuid"` @@ -36,7 +42,8 @@ type APIPortal struct { URL string `json:"url,omitempty" db:"url"` WorkflowStatus string `json:"workflowStatus" db:"workflow_status"` AuthType string `json:"authType" db:"auth_type"` - Configuration map[string]interface{} `json:"configuration,omitempty" db:"configuration"` + AuthConfig map[string]interface{} `json:"authConfig,omitempty" db:"auth_configuration"` + Metadata map[string]interface{} `json:"metadata,omitempty" db:"metadata"` CreatedBy string `json:"createdBy,omitempty" db:"created_by"` UpdatedBy string `json:"updatedBy,omitempty" db:"updated_by"` CreatedAt time.Time `json:"createdAt" db:"created_at"` diff --git a/platform-api/internal/repository/api_portal.go b/platform-api/internal/repository/api_portal.go index ca4195cd31..60cc9867e3 100644 --- a/platform-api/internal/repository/api_portal.go +++ b/platform-api/internal/repository/api_portal.go @@ -42,7 +42,7 @@ func NewAPIPortalRepo(db *database.DB) APIPortalRepository { // apiPortalSelectColumns are the api_portals columns selected in every query, in scan order. const apiPortalSelectColumns = ` uuid, organization_uuid, handle, display_name, description, url, - workflow_status, auth_type, configuration, + workflow_status, auth_type, auth_configuration, metadata, created_by, updated_by, created_at, updated_at ` @@ -52,10 +52,10 @@ func scanAPIPortalRow(scanner interface { }) (*model.APIPortal, error) { portal := &model.APIPortal{} var description, url, createdBy, updatedBy sql.NullString - var configurationBytes []byte + var authConfigBytes, metadataBytes []byte if err := scanner.Scan( &portal.ID, &portal.OrganizationID, &portal.Handle, &portal.Name, &description, &url, - &portal.WorkflowStatus, &portal.AuthType, &configurationBytes, + &portal.WorkflowStatus, &portal.AuthType, &authConfigBytes, &metadataBytes, &createdBy, &updatedBy, &portal.CreatedAt, &portal.UpdatedAt, ); err != nil { return nil, err @@ -64,53 +64,71 @@ func scanAPIPortalRow(scanner interface { portal.URL = url.String portal.CreatedBy = createdBy.String portal.UpdatedBy = updatedBy.String - if len(configurationBytes) > 0 { - if err := json.Unmarshal(configurationBytes, &portal.Configuration); err != nil { - return nil, fmt.Errorf("failed to unmarshal configuration: %w", err) - } + authConfig, err := unmarshalAPIPortalBlob(authConfigBytes, "auth_configuration") + if err != nil { + return nil, err } - // Normalize to a non-nil empty map so callers can range/read/write without - // nil-guarding. Handles both the empty-bytes case (defensive) and the - // unlikely case where json.Unmarshal returns a nil map. - if portal.Configuration == nil { - portal.Configuration = map[string]interface{}{} + portal.AuthConfig = authConfig + metadata, err := unmarshalAPIPortalBlob(metadataBytes, "metadata") + if err != nil { + return nil, err } + portal.Metadata = metadata return portal, nil } -// marshalAPIPortalConfiguration serializes the configuration map to JSON bytes for the -// configuration BYTEA/BLOB/VARBINARY column. A nil map is stored as an empty JSON object -// so the NOT NULL column always has valid content; readers (scanAPIPortalRow) mirror -// this by normalizing empty/{} back to an empty map, keeping the round-trip stable. -func marshalAPIPortalConfiguration(cfg map[string]interface{}) ([]byte, error) { - if cfg == nil { +// marshalAPIPortalBlob serializes a JSON blob column value. A nil map becomes an +// empty JSON object so the NOT NULL BYTEA/BLOB/VARBINARY column always has +// valid content; readers (unmarshalAPIPortalBlob) mirror this by normalizing +// empty/{} back to an empty map so callers never nil-check. +func marshalAPIPortalBlob(m map[string]interface{}, field string) ([]byte, error) { + if m == nil { return []byte("{}"), nil } - b, err := json.Marshal(cfg) + b, err := json.Marshal(m) if err != nil { - return nil, fmt.Errorf("failed to marshal configuration: %w", err) + return nil, fmt.Errorf("failed to marshal %s: %w", field, err) } return b, nil } +// unmarshalAPIPortalBlob deserializes a JSON blob and normalizes the result to +// a non-nil map. +func unmarshalAPIPortalBlob(b []byte, field string) (map[string]interface{}, error) { + m := map[string]interface{}{} + if len(b) > 0 { + if err := json.Unmarshal(b, &m); err != nil { + return nil, fmt.Errorf("failed to unmarshal %s: %w", field, err) + } + } + if m == nil { + m = map[string]interface{}{} + } + return m, nil +} + // Create inserts a new API Portal row. func (r *APIPortalRepo) Create(portal *model.APIPortal) error { now := time.Now().UTC() portal.CreatedAt = now portal.UpdatedAt = now - configBytes, err := marshalAPIPortalConfiguration(portal.Configuration) + authConfigBytes, err := marshalAPIPortalBlob(portal.AuthConfig, "auth_configuration") + if err != nil { + return err + } + metadataBytes, err := marshalAPIPortalBlob(portal.Metadata, "metadata") if err != nil { return err } query := ` INSERT INTO api_portals (uuid, organization_uuid, handle, display_name, description, url, - workflow_status, auth_type, configuration, + workflow_status, auth_type, auth_configuration, metadata, created_by, updated_by, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` _, err = r.db.Exec(r.db.Rebind(query), portal.ID, portal.OrganizationID, portal.Handle, portal.Name, portal.Description, portal.URL, - portal.WorkflowStatus, portal.AuthType, configBytes, + portal.WorkflowStatus, portal.AuthType, authConfigBytes, metadataBytes, portal.CreatedBy, portal.UpdatedBy, portal.CreatedAt, portal.UpdatedAt, ) return err @@ -218,19 +236,25 @@ func (r *APIPortalRepo) Count(orgUUID string, workflowStatus *string, search str // responsible for populating UpdatedBy before invoking. func (r *APIPortalRepo) Update(portal *model.APIPortal) error { portal.UpdatedAt = time.Now().UTC() - configBytes, err := marshalAPIPortalConfiguration(portal.Configuration) + authConfigBytes, err := marshalAPIPortalBlob(portal.AuthConfig, "auth_configuration") + if err != nil { + return err + } + metadataBytes, err := marshalAPIPortalBlob(portal.Metadata, "metadata") if err != nil { return err } query := ` UPDATE api_portals SET display_name = ?, description = ?, url = ?, workflow_status = ?, - auth_type = ?, configuration = ?, updated_by = ?, updated_at = ? + auth_type = ?, auth_configuration = ?, metadata = ?, + updated_by = ?, updated_at = ? WHERE uuid = ? AND organization_uuid = ? ` result, err := r.db.Exec(r.db.Rebind(query), portal.Name, portal.Description, portal.URL, portal.WorkflowStatus, - portal.AuthType, configBytes, portal.UpdatedBy, portal.UpdatedAt, + portal.AuthType, authConfigBytes, metadataBytes, + portal.UpdatedBy, portal.UpdatedAt, portal.ID, portal.OrganizationID, ) if err != nil { diff --git a/platform-api/internal/repository/api_portal_test.go b/platform-api/internal/repository/api_portal_test.go index 4781ecfb2a..0dcd39d651 100644 --- a/platform-api/internal/repository/api_portal_test.go +++ b/platform-api/internal/repository/api_portal_test.go @@ -52,7 +52,7 @@ func newTestAPIPortal(uuid, orgUUID, handle string) *model.APIPortal { URL: "https://" + handle + ".example.com", WorkflowStatus: constants.APIPortalWorkflowStatusPending, AuthType: constants.APIPortalAuthTypeLocal, - Configuration: map[string]interface{}{"foo": "bar"}, + AuthConfig: map[string]interface{}{"foo": "bar"}, CreatedBy: "tester", UpdatedBy: "tester", } @@ -82,8 +82,8 @@ func TestAPIPortalRepo_CreateAndGet(t *testing.T) { if got.Handle != portal.Handle || got.Name != portal.Name || got.URL != portal.URL { t.Errorf("GetByUUID: field mismatch; got %+v", got) } - if got.Configuration["foo"] != "bar" { - t.Errorf("configuration not round-tripped; got %v", got.Configuration) + if got.AuthConfig["foo"] != "bar" { + t.Errorf("configuration not round-tripped; got %v", got.AuthConfig) } // Get by handle. @@ -123,7 +123,7 @@ func TestAPIPortalRepo_Create_SetsDefaults(t *testing.T) { } } -func TestAPIPortalRepo_Create_ConfigurationRoundTrip_Nil(t *testing.T) { +func TestAPIPortalRepo_Create_AuthConfigRoundTrip_Nil(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() @@ -132,7 +132,7 @@ func TestAPIPortalRepo_Create_ConfigurationRoundTrip_Nil(t *testing.T) { repo := NewAPIPortalRepo(db) portal := newTestAPIPortal("portal-cfg-nil", orgUUID, "cfg-nil") - portal.Configuration = nil // will be stored as {} and read back as empty map + portal.AuthConfig = nil // will be stored as {} and read back as empty map if err := repo.Create(portal); err != nil { t.Fatalf("Create: %v", err) @@ -141,15 +141,15 @@ func TestAPIPortalRepo_Create_ConfigurationRoundTrip_Nil(t *testing.T) { if err != nil { t.Fatalf("GetByUUID: %v", err) } - if got.Configuration == nil { - t.Fatal("Configuration is nil after round-trip; expected non-nil empty map") + if got.AuthConfig == nil { + t.Fatal("AuthConfig is nil after round-trip; expected non-nil empty map") } - if len(got.Configuration) != 0 { - t.Errorf("Configuration expected empty; got %v", got.Configuration) + if len(got.AuthConfig) != 0 { + t.Errorf("AuthConfig expected empty; got %v", got.AuthConfig) } } -func TestAPIPortalRepo_Create_ConfigurationRoundTrip_Populated(t *testing.T) { +func TestAPIPortalRepo_Create_AuthConfigRoundTrip_Populated(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() @@ -158,7 +158,7 @@ func TestAPIPortalRepo_Create_ConfigurationRoundTrip_Populated(t *testing.T) { repo := NewAPIPortalRepo(db) portal := newTestAPIPortal("portal-cfg-full", orgUUID, "cfg-full") - portal.Configuration = map[string]interface{}{ + portal.AuthConfig = map[string]interface{}{ "stsTokenUrl": "https://sts.example.com/token", "clientId": "abc", "audience": []interface{}{"aud-1", "aud-2"}, @@ -171,15 +171,15 @@ func TestAPIPortalRepo_Create_ConfigurationRoundTrip_Populated(t *testing.T) { if err != nil { t.Fatalf("GetByUUID: %v", err) } - if got.Configuration["stsTokenUrl"] != "https://sts.example.com/token" { - t.Errorf("stsTokenUrl round-trip failed; got %v", got.Configuration["stsTokenUrl"]) + if got.AuthConfig["stsTokenUrl"] != "https://sts.example.com/token" { + t.Errorf("stsTokenUrl round-trip failed; got %v", got.AuthConfig["stsTokenUrl"]) } - if got.Configuration["clientId"] != "abc" { - t.Errorf("clientId round-trip failed; got %v", got.Configuration["clientId"]) + if got.AuthConfig["clientId"] != "abc" { + t.Errorf("clientId round-trip failed; got %v", got.AuthConfig["clientId"]) } - aud, ok := got.Configuration["audience"].([]interface{}) + aud, ok := got.AuthConfig["audience"].([]interface{}) if !ok || len(aud) != 2 || aud[0] != "aud-1" || aud[1] != "aud-2" { - t.Errorf("audience round-trip failed; got %v", got.Configuration["audience"]) + t.Errorf("audience round-trip failed; got %v", got.AuthConfig["audience"]) } } @@ -394,7 +394,7 @@ func TestAPIPortalRepo_Update(t *testing.T) { portal.URL = "https://renamed.example.com" portal.WorkflowStatus = constants.APIPortalWorkflowStatusActive portal.AuthType = constants.APIPortalAuthTypeOAuth2 - portal.Configuration = map[string]interface{}{"stsTokenUrl": "https://sts/x"} + portal.AuthConfig = map[string]interface{}{"stsTokenUrl": "https://sts/x"} portal.UpdatedBy = "editor" portal.Handle = "attempted-rename" // immutable — must NOT stick @@ -416,8 +416,8 @@ func TestAPIPortalRepo_Update(t *testing.T) { got.UpdatedBy != "editor" { t.Errorf("mutable fields not persisted; got %+v", got) } - if got.Configuration["stsTokenUrl"] != "https://sts/x" { - t.Errorf("configuration not persisted; got %v", got.Configuration) + if got.AuthConfig["stsTokenUrl"] != "https://sts/x" { + t.Errorf("configuration not persisted; got %v", got.AuthConfig) } if got.Handle != "upd" { t.Errorf("handle was mutated despite being immutable; want %q, got %q", "upd", got.Handle) diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index ea26bb9618..f83effc947 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -248,7 +248,6 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, projectService := service.NewProjectService(projectRepo, orgRepo, apiRepo, mcpProxyRepo, appRepo, auditRepo, identityService, slogger) gatewayEventsService := service.NewGatewayEventsService(eventHub, identityService, slogger) appService := service.NewApplicationService(appRepo, projectRepo, orgRepo, apiRepo, gatewayEventsService, auditRepo, identityService, slogger) - apiPortalService := service.NewAPIPortalService(apiPortalRepo, orgRepo, auditRepo, identityService, slogger) apiService := service.NewAPIService(apiRepo, projectRepo, orgRepo, gatewayRepo, deploymentRepo, subscriptionPlanRepo, customPolicyRepo, gatewayEventsService, apiUtil, slogger, auditRepo, identityService) gatewayService := service.NewGatewayService(gatewayRepo, orgRepo, apiRepo, customPolicyRepo, gatewayEventsService, slogger, cfg.Gateway.EnableVersionVerification, cfg.Gateway.EnableFunctionalityTypeVerification, auditRepo, identityService) @@ -326,6 +325,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, return nil, fmt.Errorf("failed to initialize secret vault: %w", vaultErr) } secretService := service.NewSecretService(secretRepo, secretVault, identityService) + apiPortalService := service.NewAPIPortalService(apiPortalRepo, orgRepo, auditRepo, secretVault, identityService, slogger) // Initialize handlers orgHandler := handler.NewOrganizationHandler(orgService, identityService, cfg.Auth.Authorization.Mode, slogger) diff --git a/platform-api/internal/service/api_portal.go b/platform-api/internal/service/api_portal.go index 1df322c6a1..a5ffc321fc 100644 --- a/platform-api/internal/service/api_portal.go +++ b/platform-api/internal/service/api_portal.go @@ -18,6 +18,8 @@ package service import ( + "context" + "encoding/base64" "fmt" "log/slog" "net/url" @@ -30,6 +32,7 @@ import ( "github.com/wso2/api-platform/platform-api/internal/model" "github.com/wso2/api-platform/platform-api/internal/repository" "github.com/wso2/api-platform/platform-api/internal/utils" + "github.com/wso2/api-platform/platform-api/internal/vault" ) // validateAPIPortalURL enforces input-time constraints on a caller-supplied @@ -71,6 +74,7 @@ type APIPortalService struct { portalRepo repository.APIPortalRepository orgRepo repository.OrganizationRepository auditRepo repository.AuditRepository + vault vault.SecretVault identity *IdentityService slogger *slog.Logger } @@ -80,6 +84,7 @@ func NewAPIPortalService( portalRepo repository.APIPortalRepository, orgRepo repository.OrganizationRepository, auditRepo repository.AuditRepository, + secretVault vault.SecretVault, identity *IdentityService, slogger *slog.Logger, ) *APIPortalService { @@ -87,11 +92,117 @@ func NewAPIPortalService( portalRepo: portalRepo, orgRepo: orgRepo, auditRepo: auditRepo, + vault: secretVault, identity: identity, slogger: slogger, } } +// validateAPIPortalAuthConfig enforces per-authType constraints on the config +// map. For `local` the map must be empty; for `oauth2` all required keys must +// be present and non-empty strings, and no unknown keys are allowed. +func validateAPIPortalAuthConfig(authType string, cfg map[string]interface{}) error { + switch authType { + case constants.APIPortalAuthTypeLocal: + if len(cfg) > 0 { + return apperror.ValidationFailed.New( + "authConfig must be empty when authType is local.") + } + return nil + case constants.APIPortalAuthTypeOAuth2: + for _, key := range constants.APIPortalOAuth2RequiredAuthConfigKeys { + v, ok := cfg[key] + if !ok { + return apperror.ValidationFailed.New( + fmt.Sprintf("authConfig field %q is required for authType %q.", key, authType)) + } + s, isString := v.(string) + if !isString || strings.TrimSpace(s) == "" { + return apperror.ValidationFailed.New( + fmt.Sprintf("authConfig field %q must be a non-empty string.", key)) + } + } + allowed := map[string]bool{ + constants.APIPortalAuthConfigKeySTSTokenURL: true, + constants.APIPortalAuthConfigKeyClientID: true, + constants.APIPortalAuthConfigKeyClientSecret: true, + } + for k := range cfg { + if !allowed[k] { + return apperror.ValidationFailed.New( + fmt.Sprintf("authConfig field %q is not supported for authType %q.", k, authType)) + } + } + return nil + } + return apperror.ValidationFailed.New( + fmt.Sprintf("The authType %q is not supported.", authType)) +} + +// encryptAPIPortalAuthConfigSecrets walks the sensitive-key list and encrypts +// each key's value in place. Values are base64-encoded ciphertext strings +// after this returns. Empty / nil values are removed rather than encrypted so +// we never store an encrypted empty string. +func encryptAPIPortalAuthConfigSecrets(v vault.SecretVault, cfg map[string]interface{}) error { + if cfg == nil { + return nil + } + for _, key := range constants.APIPortalAuthConfigSensitiveKeys { + raw, ok := cfg[key] + if !ok { + continue + } + if raw == nil { + delete(cfg, key) + continue + } + plaintext, isString := raw.(string) + if !isString { + return apperror.ValidationFailed.New( + fmt.Sprintf("authConfig field %q must be a string.", key)) + } + if plaintext == "" { + delete(cfg, key) + continue + } + ciphertext, err := v.Encrypt(context.Background(), plaintext) + if err != nil { + return fmt.Errorf("failed to encrypt authConfig field %q: %w", key, err) + } + cfg[key] = base64.StdEncoding.EncodeToString(ciphertext) + } + return nil +} + +// mergeAPIPortalAuthConfig returns existing + incoming, with incoming keys +// overwriting existing ones. Used on Update so a caller can rotate a single +// field (e.g. only stsTokenUrl) without having to re-send fields they don't +// want to change — including clientSecret, which they can't fetch back. +func mergeAPIPortalAuthConfig(existing, incoming map[string]interface{}) map[string]interface{} { + merged := make(map[string]interface{}, len(existing)+len(incoming)) + for k, v := range existing { + merged[k] = v + } + for k, v := range incoming { + merged[k] = v + } + return merged +} + +// copyStringMap returns a shallow copy so the service can encrypt/mutate its +// own working set without touching the caller's map (which lives in the +// generated request DTO the handler translated). +func copyStringMap(m map[string]interface{}) map[string]interface{} { + if m == nil { + return nil + } + out := make(map[string]interface{}, len(m)) + for k, v := range m { + out[k] = v + } + return out +} + // CreateAPIPortalRequest is the service-layer input for creating an API Portal. // Fields mirror the OpenAPI CreateApiPortalRequest but stay independent of the // generated types. @@ -102,20 +213,31 @@ type CreateAPIPortalRequest struct { URL string WorkflowStatus string // optional; defaults to "pending" AuthType string - Configuration map[string]interface{} + AuthConfig map[string]interface{} + Metadata map[string]interface{} } // UpdateAPIPortalRequest carries mutable fields for a partial update. Pointer // fields distinguish "not sent" (nil) from "sent as empty" (non-nil, empty). // Only whitelisted fields are respected here; Handle, ID, OrganizationID, // CreatedAt, CreatedBy are ignored per the design's immutability rules. +// +// AuthConfig on update uses merge semantics: supplied keys overwrite existing +// keys, missing keys retain their stored values. This lets a caller rotate a +// single field without re-supplying clientSecret (which they can't fetch back +// after it's been stored encrypted). +// +// Metadata on update uses replace semantics: if supplied (non-nil), it fully +// replaces the stored metadata. Callers that want a partial-update on metadata +// should GET, modify, PUT the whole thing. type UpdateAPIPortalRequest struct { Name *string Description *string URL *string WorkflowStatus *string AuthType *string - Configuration map[string]interface{} // when nil, the existing configuration is preserved + AuthConfig map[string]interface{} // when nil, existing preserved; when non-nil, merged in + Metadata map[string]interface{} // when nil, existing preserved; when non-nil, replaces } // APIPortalListOptions bundles the pagination + filter inputs for List. @@ -172,6 +294,15 @@ func (s *APIPortalService) CreateAPIPortal(req *CreateAPIPortalRequest, orgID, c return nil, apperror.ValidationFailed.New( "The workflowStatus cannot be active when url is empty.") } + // Copy the incoming authConfig so we don't mutate the caller's map when we + // encrypt secret fields in place. + authConfig := copyStringMap(req.AuthConfig) + if err := validateAPIPortalAuthConfig(authType, authConfig); err != nil { + return nil, err + } + if err := encryptAPIPortalAuthConfigSecrets(s.vault, authConfig); err != nil { + return nil, err + } org, err := s.orgRepo.GetOrganizationByUUID(orgID) if err != nil { @@ -199,7 +330,8 @@ func (s *APIPortalService) CreateAPIPortal(req *CreateAPIPortalRequest, orgID, c URL: portalURL, WorkflowStatus: workflowStatus, AuthType: authType, - Configuration: req.Configuration, + AuthConfig: authConfig, + Metadata: req.Metadata, CreatedBy: actor, UpdatedBy: actor, } @@ -320,8 +452,24 @@ func (s *APIPortalService) UpdateAPIPortal(handle string, req *UpdateAPIPortalRe } portal.AuthType = at } - if req.Configuration != nil { - portal.Configuration = req.Configuration + if req.AuthConfig != nil { + // Merge into the stored authConfig — supplied keys overwrite existing, + // missing keys are retained. Encrypt any newly supplied sensitive + // fields before persistence; existing encrypted values pass through + // untouched because their key isn't in the incoming map. + incoming := copyStringMap(req.AuthConfig) + if err := encryptAPIPortalAuthConfigSecrets(s.vault, incoming); err != nil { + return nil, err + } + portal.AuthConfig = mergeAPIPortalAuthConfig(portal.AuthConfig, incoming) + } + if req.Metadata != nil { + // Metadata is opaque pass-through; supplied map fully replaces stored. + portal.Metadata = copyStringMap(req.Metadata) + } + // Re-validate authConfig against the effective authType after all mutations. + if err := validateAPIPortalAuthConfig(portal.AuthType, portal.AuthConfig); err != nil { + return nil, err } // After applying all whitelisted mutations, enforce the cross-field rule: // a portal cannot be in the active state without a URL. This catches both diff --git a/platform-api/internal/service/api_portal_test.go b/platform-api/internal/service/api_portal_test.go index 38530b6c1f..8503a8ec3c 100644 --- a/platform-api/internal/service/api_portal_test.go +++ b/platform-api/internal/service/api_portal_test.go @@ -18,6 +18,7 @@ package service import ( + "bytes" "errors" "testing" @@ -25,8 +26,21 @@ import ( "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/model" "github.com/wso2/api-platform/platform-api/internal/repository" + "github.com/wso2/api-platform/platform-api/internal/vault" ) +// newTestVault returns a real InHouseVault seeded with a deterministic 32-byte +// key. Using the real implementation (rather than a fake) validates the +// encrypt/decrypt round-trip actually works. +func newTestVault(t *testing.T) vault.SecretVault { + t.Helper() + v, err := vault.NewInHouseVault(bytes.Repeat([]byte("t"), 32)) + if err != nil { + t.Fatalf("test vault: %v", err) + } + return v +} + // --- mocks --- // Each mock embeds the interface so unimplemented methods panic on invocation, // making it obvious when a test exercises an unstubbed code path. @@ -122,14 +136,15 @@ func (m *mockAPIPortalAuditRepository) Record(action, resourceUUID, resourceType return nil } -// newTestAPIPortalService wires the three mocks together. identity + slogger -// are nil because the service does not invoke them. -func newTestAPIPortalService( +// newTestAPIPortalService wires the three mocks together with a real +// InHouseVault. identity + slogger are nil because the service does not invoke +// them. +func newTestAPIPortalService(t *testing.T, portalRepo repository.APIPortalRepository, orgRepo repository.OrganizationRepository, auditRepo repository.AuditRepository, ) *APIPortalService { - return NewAPIPortalService(portalRepo, orgRepo, auditRepo, nil, nil) + return NewAPIPortalService(portalRepo, orgRepo, auditRepo, newTestVault(t), nil, nil) } func apiPortalStrPtr(s string) *string { return &s } @@ -140,15 +155,15 @@ func TestAPIPortalService_CreateAPIPortal_HappyPath(t *testing.T) { portalRepo := &mockAPIPortalRepository{} orgRepo := &mockAPIPortalOrgRepository{result: &model.Organization{}} auditRepo := &mockAPIPortalAuditRepository{} - svc := newTestAPIPortalService(portalRepo, orgRepo, auditRepo) + svc := newTestAPIPortalService(t, portalRepo, orgRepo, auditRepo) req := &CreateAPIPortalRequest{ - Handle: "acme", - Name: "Acme Portal", - Description: "test", - URL: "https://acme.example.com", - AuthType: constants.APIPortalAuthTypeLocal, - Configuration: map[string]interface{}{"key": "value"}, + Handle: "acme", + Name: "Acme Portal", + Description: "test", + URL: "https://acme.example.com", + AuthType: constants.APIPortalAuthTypeLocal, + Metadata: map[string]interface{}{"stsIssuer": "https://sts.example.com"}, } got, err := svc.CreateAPIPortal(req, "org-1", "user-1") if err != nil { @@ -175,7 +190,7 @@ func TestAPIPortalService_CreateAPIPortal_HappyPath(t *testing.T) { } func TestAPIPortalService_CreateAPIPortal_MissingName(t *testing.T) { - svc := newTestAPIPortalService(&mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}) + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}) _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ Handle: "acme", AuthType: constants.APIPortalAuthTypeLocal, @@ -189,7 +204,7 @@ func TestAPIPortalService_CreateAPIPortal_MissingName(t *testing.T) { } func TestAPIPortalService_CreateAPIPortal_InvalidHandle(t *testing.T) { - svc := newTestAPIPortalService(&mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}) + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}) _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ Handle: "AB", // too short + uppercase Name: "x", @@ -201,7 +216,7 @@ func TestAPIPortalService_CreateAPIPortal_InvalidHandle(t *testing.T) { } func TestAPIPortalService_CreateAPIPortal_InvalidAuthType(t *testing.T) { - svc := newTestAPIPortalService(&mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}) + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}) _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ Handle: "acme", Name: "Acme", @@ -216,7 +231,7 @@ func TestAPIPortalService_CreateAPIPortal_InvalidAuthType(t *testing.T) { } func TestAPIPortalService_CreateAPIPortal_InvalidWorkflowStatus(t *testing.T) { - svc := newTestAPIPortalService(&mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}) + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}) _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ Handle: "acme", Name: "Acme", @@ -229,7 +244,7 @@ func TestAPIPortalService_CreateAPIPortal_InvalidWorkflowStatus(t *testing.T) { } func TestAPIPortalService_CreateAPIPortal_OrgNotFound(t *testing.T) { - svc := newTestAPIPortalService(&mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: nil}, &mockAPIPortalAuditRepository{}) + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: nil}, &mockAPIPortalAuditRepository{}) _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ Handle: "acme", Name: "Acme", AuthType: constants.APIPortalAuthTypeLocal, }, "org-missing", "user-1") @@ -239,7 +254,7 @@ func TestAPIPortalService_CreateAPIPortal_OrgNotFound(t *testing.T) { } func TestAPIPortalService_CreateAPIPortal_HandleAlreadyExists(t *testing.T) { - svc := newTestAPIPortalService( + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{existsResult: true}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}, @@ -255,7 +270,7 @@ func TestAPIPortalService_CreateAPIPortal_HandleAlreadyExists(t *testing.T) { func TestAPIPortalService_CreateAPIPortal_RaceOnUniqueConstraint(t *testing.T) { // Exists() returns false (no row yet), then Create() races against another // insert and hits the UNIQUE constraint. Service must translate to Conflict. - svc := newTestAPIPortalService( + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{existsResult: false, createReturnUnique: true}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}, @@ -282,7 +297,7 @@ func TestAPIPortalService_CreateAPIPortal_InvalidURL(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - svc := newTestAPIPortalService( + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}, @@ -301,7 +316,7 @@ func TestAPIPortalService_CreateAPIPortal_InvalidURL(t *testing.T) { } func TestAPIPortalService_CreateAPIPortal_ValidHTTPSAccepted(t *testing.T) { - svc := newTestAPIPortalService( + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}, @@ -322,7 +337,7 @@ func TestAPIPortalService_CreateAPIPortal_ValidHTTPSAccepted(t *testing.T) { func TestAPIPortalService_CreateAPIPortal_EmptyURLAllowed(t *testing.T) { // Cloud provisioning starts with URL null; empty must pass validation. - svc := newTestAPIPortalService( + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}, @@ -339,7 +354,7 @@ func TestAPIPortalService_CreateAPIPortal_EmptyURLAllowed(t *testing.T) { } func TestAPIPortalService_CreateAPIPortal_ActiveWithURL(t *testing.T) { - svc := newTestAPIPortalService( + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}, @@ -358,7 +373,7 @@ func TestAPIPortalService_CreateAPIPortal_ActiveWithURL(t *testing.T) { } func TestAPIPortalService_CreateAPIPortal_ActiveWithoutURLRejected(t *testing.T) { - svc := newTestAPIPortalService( + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}, @@ -374,7 +389,7 @@ func TestAPIPortalService_CreateAPIPortal_ActiveWithoutURLRejected(t *testing.T) } func TestAPIPortalService_CreateAPIPortal_FailedStatusRejected(t *testing.T) { - svc := newTestAPIPortalService( + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}, @@ -397,7 +412,7 @@ func TestAPIPortalService_UpdateAPIPortal_ActivateWithoutURLRejected(t *testing. WorkflowStatus: constants.APIPortalWorkflowStatusPending, AuthType: constants.APIPortalAuthTypeLocal, } - svc := newTestAPIPortalService( + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{getResult: existing}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}, @@ -418,7 +433,7 @@ func TestAPIPortalService_UpdateAPIPortal_ClearURLWhileActiveRejected(t *testing WorkflowStatus: constants.APIPortalWorkflowStatusActive, AuthType: constants.APIPortalAuthTypeLocal, } - svc := newTestAPIPortalService( + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{getResult: existing}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}, @@ -439,7 +454,7 @@ func TestAPIPortalService_UpdateAPIPortal_ActivateWithNewURL(t *testing.T) { WorkflowStatus: constants.APIPortalWorkflowStatusPending, AuthType: constants.APIPortalAuthTypeLocal, } - svc := newTestAPIPortalService( + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{getResult: existing}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}, @@ -462,7 +477,7 @@ func TestAPIPortalService_UpdateAPIPortal_InvalidURLRejected(t *testing.T) { Name: "Acme", WorkflowStatus: constants.APIPortalWorkflowStatusActive, AuthType: constants.APIPortalAuthTypeLocal, } - svc := newTestAPIPortalService( + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{getResult: existing}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}, @@ -479,7 +494,7 @@ func TestAPIPortalService_UpdateAPIPortal_InvalidURLRejected(t *testing.T) { func TestAPIPortalService_GetAPIPortal_HappyPath(t *testing.T) { portal := &model.APIPortal{ID: "p1", Handle: "acme", OrganizationID: "org-1"} - svc := newTestAPIPortalService( + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{getResult: portal}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}, @@ -494,7 +509,7 @@ func TestAPIPortalService_GetAPIPortal_HappyPath(t *testing.T) { } func TestAPIPortalService_GetAPIPortal_NotFound(t *testing.T) { - svc := newTestAPIPortalService(&mockAPIPortalRepository{getResult: nil}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{getResult: nil}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) _, err := svc.GetAPIPortal("ghost", "org-1") if err == nil || !apperror.APIPortalNotFound.Is(err) { t.Fatalf("want APIPortalNotFound, got %v", err) @@ -505,7 +520,7 @@ func TestAPIPortalService_GetAPIPortal_NotFound(t *testing.T) { func TestAPIPortalService_ListAPIPortals_HappyPath(t *testing.T) { portals := []*model.APIPortal{{ID: "p1", Handle: "a"}, {ID: "p2", Handle: "b"}} - svc := newTestAPIPortalService( + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{listResult: portals, countResult: 5}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}, @@ -523,7 +538,7 @@ func TestAPIPortalService_ListAPIPortals_HappyPath(t *testing.T) { } func TestAPIPortalService_ListAPIPortals_OrgNotFound(t *testing.T) { - svc := newTestAPIPortalService(&mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: nil}, &mockAPIPortalAuditRepository{}) + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: nil}, &mockAPIPortalAuditRepository{}) _, err := svc.ListAPIPortals("org-missing", APIPortalListOptions{}) if err == nil || !apperror.OrganizationNotFound.Is(err) { t.Fatalf("want OrganizationNotFound, got %v", err) @@ -531,7 +546,7 @@ func TestAPIPortalService_ListAPIPortals_OrgNotFound(t *testing.T) { } func TestAPIPortalService_ListAPIPortals_LimitClamping(t *testing.T) { - svc := newTestAPIPortalService( + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{listResult: nil, countResult: 0}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}, @@ -549,7 +564,7 @@ func TestAPIPortalService_ListAPIPortals_LimitClamping(t *testing.T) { } func TestAPIPortalService_ListAPIPortals_InvalidWorkflowStatus(t *testing.T) { - svc := newTestAPIPortalService(&mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}) + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}) _, err := svc.ListAPIPortals("org-1", APIPortalListOptions{WorkflowStatus: apiPortalStrPtr("bogus")}) if err == nil || !apperror.ValidationFailed.Is(err) { t.Fatalf("want ValidationFailed, got %v", err) @@ -567,13 +582,17 @@ func TestAPIPortalService_UpdateAPIPortal_HappyPath(t *testing.T) { } portalRepo := &mockAPIPortalRepository{getResult: existing} auditRepo := &mockAPIPortalAuditRepository{} - svc := newTestAPIPortalService(portalRepo, &mockAPIPortalOrgRepository{}, auditRepo) + svc := newTestAPIPortalService(t, portalRepo, &mockAPIPortalOrgRepository{}, auditRepo) req := &UpdateAPIPortalRequest{ Name: apiPortalStrPtr("Renamed"), WorkflowStatus: apiPortalStrPtr(constants.APIPortalWorkflowStatusActive), AuthType: apiPortalStrPtr(constants.APIPortalAuthTypeOAuth2), - Configuration: map[string]interface{}{"stsTokenUrl": "https://sts"}, + AuthConfig: map[string]interface{}{ + "stsTokenUrl": "https://sts.example.com/token", + "clientId": "abc", + "clientSecret": "s3cr3t", + }, } got, err := svc.UpdateAPIPortal("acme", req, "org-1", "editor") if err != nil { @@ -604,7 +623,7 @@ func TestAPIPortalService_UpdateAPIPortal_PartialUpdate(t *testing.T) { WorkflowStatus: constants.APIPortalWorkflowStatusActive, AuthType: constants.APIPortalAuthTypeLocal, } - svc := newTestAPIPortalService(&mockAPIPortalRepository{getResult: existing}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{getResult: existing}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) // Only Description supplied; everything else must remain unchanged. got, err := svc.UpdateAPIPortal("acme", &UpdateAPIPortalRequest{Description: apiPortalStrPtr("new desc")}, "org-1", "editor") if err != nil { @@ -621,7 +640,7 @@ func TestAPIPortalService_UpdateAPIPortal_PartialUpdate(t *testing.T) { } func TestAPIPortalService_UpdateAPIPortal_NotFound(t *testing.T) { - svc := newTestAPIPortalService(&mockAPIPortalRepository{getResult: nil}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{getResult: nil}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) _, err := svc.UpdateAPIPortal("ghost", &UpdateAPIPortalRequest{Name: apiPortalStrPtr("x")}, "org-1", "editor") if err == nil || !apperror.APIPortalNotFound.Is(err) { t.Fatalf("want APIPortalNotFound, got %v", err) @@ -630,7 +649,7 @@ func TestAPIPortalService_UpdateAPIPortal_NotFound(t *testing.T) { func TestAPIPortalService_UpdateAPIPortal_EmptyName(t *testing.T) { existing := &model.APIPortal{ID: "p1", Handle: "acme", OrganizationID: "org-1", Name: "old"} - svc := newTestAPIPortalService(&mockAPIPortalRepository{getResult: existing}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{getResult: existing}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) _, err := svc.UpdateAPIPortal("acme", &UpdateAPIPortalRequest{Name: apiPortalStrPtr(" ")}, "org-1", "editor") if err == nil || !apperror.ValidationFailed.Is(err) { t.Fatalf("want ValidationFailed for empty name, got %v", err) @@ -639,7 +658,7 @@ func TestAPIPortalService_UpdateAPIPortal_EmptyName(t *testing.T) { func TestAPIPortalService_UpdateAPIPortal_InvalidWorkflowStatus(t *testing.T) { existing := &model.APIPortal{ID: "p1", Handle: "acme", OrganizationID: "org-1"} - svc := newTestAPIPortalService(&mockAPIPortalRepository{getResult: existing}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{getResult: existing}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) _, err := svc.UpdateAPIPortal("acme", &UpdateAPIPortalRequest{WorkflowStatus: apiPortalStrPtr("bogus")}, "org-1", "editor") if err == nil || !apperror.ValidationFailed.Is(err) { t.Fatalf("want ValidationFailed, got %v", err) @@ -652,7 +671,7 @@ func TestAPIPortalService_DeleteAPIPortal_HappyPath(t *testing.T) { existing := &model.APIPortal{ID: "p1", Handle: "acme", OrganizationID: "org-1"} portalRepo := &mockAPIPortalRepository{getResult: existing} auditRepo := &mockAPIPortalAuditRepository{} - svc := newTestAPIPortalService(portalRepo, &mockAPIPortalOrgRepository{}, auditRepo) + svc := newTestAPIPortalService(t, portalRepo, &mockAPIPortalOrgRepository{}, auditRepo) if err := svc.DeleteAPIPortal("acme", "org-1", "actor"); err != nil { t.Fatalf("DeleteAPIPortal: %v", err) } @@ -665,7 +684,7 @@ func TestAPIPortalService_DeleteAPIPortal_HappyPath(t *testing.T) { } func TestAPIPortalService_DeleteAPIPortal_NotFound(t *testing.T) { - svc := newTestAPIPortalService(&mockAPIPortalRepository{getResult: nil}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{getResult: nil}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) err := svc.DeleteAPIPortal("ghost", "org-1", "actor") if err == nil || !apperror.APIPortalNotFound.Is(err) { t.Fatalf("want APIPortalNotFound, got %v", err) diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index ac74e9f46f..e0bba81948 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -8824,13 +8824,42 @@ components: pagination: $ref: '#/components/schemas/Pagination' - ApiPortalConfig: - title: API Portal auth-type-specific config + ApiPortalAuthConfig: + title: API Portal outbound authentication material type: object description: | - Configuration for how Platform API authenticates to the portal's admin - API. Shape depends on `authType`; treated as an opaque object at the - wire level. + Platform-API's outbound authentication material for the portal admin + API. Shape depends on `authType`: + - `local` → must be empty. + - `oauth2` → `stsTokenUrl`, `clientId`, `clientSecret` are all required. + `clientSecret` is write-only: accepted on create/update requests, persisted + encrypted at rest, and never returned on read. + additionalProperties: false + properties: + stsTokenUrl: + type: string + format: uri + description: Token endpoint of the STS Platform-API POSTs the client_credentials grant to. + example: "https://sts.example.com/oauth2/token" + clientId: + type: string + description: Registered client identifier in the STS. + example: "acme-portal-client" + clientSecret: + type: string + writeOnly: true + description: >- + Registered client secret. Accepted only in create/update requests; + never returned in responses. Persisted encrypted server-side. + + ApiPortalMetadata: + title: API Portal metadata + type: object + description: >- + Free-form pass-through metadata for the portal pod (e.g. cloud-side OIDC + endpoints the portal uses for consumer login). Platform-API stores and + returns this as-is; it is not consumed by the outbound authentication + path. additionalProperties: true ApiPortalResponse: @@ -8894,8 +8923,10 @@ components: Determines how Platform API authenticates to the portal's admin API and selects the shape of the `config` object. example: "oauth2" - config: - $ref: '#/components/schemas/ApiPortalConfig' + authConfig: + $ref: '#/components/schemas/ApiPortalAuthConfig' + metadata: + $ref: '#/components/schemas/ApiPortalMetadata' createdAt: type: string format: date-time @@ -8988,8 +9019,10 @@ components: requires a non-empty `url`; the request is rejected otherwise. `failed` cannot be set on create — a portal is never created in a failed state. - config: - $ref: '#/components/schemas/ApiPortalConfig' + authConfig: + $ref: '#/components/schemas/ApiPortalAuthConfig' + metadata: + $ref: '#/components/schemas/ApiPortalMetadata' UpdateApiPortalRequest: title: Update API Portal request @@ -9014,8 +9047,10 @@ components: authType: type: string enum: [local, oauth2] - config: - $ref: '#/components/schemas/ApiPortalConfig' + authConfig: + $ref: '#/components/schemas/ApiPortalAuthConfig' + metadata: + $ref: '#/components/schemas/ApiPortalMetadata' ApiPortalListResponse: title: API Portal list response From 6435f862d969b4170a5228573943cc6db855e396 Mon Sep 17 00:00:00 2001 From: dushaniw Date: Mon, 17 Aug 2026 18:05:31 +0530 Subject: [PATCH 11/25] Add outbound AuthProvider surface for API Portal callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the AuthProvider interface consumed by any component that calls a portal's admin REST endpoints (publisher, future health-check, etc.), plus two implementations and a per-portal registry. - LocalAuthProvider mints RS256 JWTs using the same private key AuthLoginHandler already loads (Auth.JWT.PrivateKeyFile). No new config surface. Claims: sub=platform-api-system, iss=platform-api, roles=[platform-api-system]. Token cached with mutex-guarded refresh. - ClientCredentialsAuthProvider POSTs the OAuth2 client_credentials grant to the STS token URL, caches the returned access_token until ~30s before expires_in, and mutex-guards refresh so concurrent callers issue a single fetch (thundering-herd protection). - APIPortalAuthRegistry is the process-wide cache keyed by portal handle. Get() builds the concrete provider on demand — decrypting the stored oauth2 client_secret via the shared vault — and returns the same instance across concurrent calls so token caches stay warm. Invalidate() evicts an entry. Service integration: APIPortalService gains an authRegistry field. Update and Delete call authRegistry.Invalidate(handle) after their audit records so cached providers reflect config changes (or, for Delete, don't leak). Server wires the registry with the existing JWT config and secret vault; no new config surface. Tests: 14 new tests covering RS256 mint + JWKS-verify, STS request body shape, caching, Invalidate, non-2xx STS handling, thundering- herd guard, registry same-instance semantics, decryption path, and misconfig/bad-ciphertext error paths. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../handler/api_portal_integration_test.go | 2 +- platform-api/internal/server/server.go | 3 +- platform-api/internal/service/api_portal.go | 41 +- .../internal/service/api_portal_auth.go | 319 +++++++++++++++ .../internal/service/api_portal_auth_test.go | 381 ++++++++++++++++++ .../internal/service/api_portal_test.go | 2 +- 6 files changed, 733 insertions(+), 15 deletions(-) create mode 100644 platform-api/internal/service/api_portal_auth.go create mode 100644 platform-api/internal/service/api_portal_auth_test.go diff --git a/platform-api/internal/handler/api_portal_integration_test.go b/platform-api/internal/handler/api_portal_integration_test.go index 46c2865862..c267f8aac9 100644 --- a/platform-api/internal/handler/api_portal_integration_test.go +++ b/platform-api/internal/handler/api_portal_integration_test.go @@ -85,7 +85,7 @@ func setupAPIPortalHandlerEnv(t *testing.T) (http.Handler, *database.DB, func()) portalRepo := repository.NewAPIPortalRepo(db) orgRepo := repository.NewOrganizationRepo(db) identityService := service.NewIdentityService(repository.NewUserIdentityMappingRepo(db)) - svc := service.NewAPIPortalService(portalRepo, orgRepo, noopAudit{}, apiPortalTestVault(t), identityService, slog.Default()) + svc := service.NewAPIPortalService(portalRepo, orgRepo, noopAudit{}, apiPortalTestVault(t), nil, identityService, slog.Default()) h := NewAPIPortalHandler(svc, identityService, slog.Default()) mux := http.NewServeMux() diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index f83effc947..54e365fb00 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -325,7 +325,8 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, return nil, fmt.Errorf("failed to initialize secret vault: %w", vaultErr) } secretService := service.NewSecretService(secretRepo, secretVault, identityService) - apiPortalService := service.NewAPIPortalService(apiPortalRepo, orgRepo, auditRepo, secretVault, identityService, slogger) + apiPortalAuthRegistry := service.NewAPIPortalAuthRegistry(&cfg.Auth.JWT, secretVault, nil) + apiPortalService := service.NewAPIPortalService(apiPortalRepo, orgRepo, auditRepo, secretVault, apiPortalAuthRegistry, identityService, slogger) // Initialize handlers orgHandler := handler.NewOrganizationHandler(orgService, identityService, cfg.Auth.Authorization.Mode, slogger) diff --git a/platform-api/internal/service/api_portal.go b/platform-api/internal/service/api_portal.go index a5ffc321fc..43d3e7d3c6 100644 --- a/platform-api/internal/service/api_portal.go +++ b/platform-api/internal/service/api_portal.go @@ -71,12 +71,13 @@ func validateAPIPortalURL(raw string) (string, error) { // the service's own request structs so the service stays independent of the // generated code. type APIPortalService struct { - portalRepo repository.APIPortalRepository - orgRepo repository.OrganizationRepository - auditRepo repository.AuditRepository - vault vault.SecretVault - identity *IdentityService - slogger *slog.Logger + portalRepo repository.APIPortalRepository + orgRepo repository.OrganizationRepository + auditRepo repository.AuditRepository + vault vault.SecretVault + authRegistry *APIPortalAuthRegistry + identity *IdentityService + slogger *slog.Logger } // NewAPIPortalService constructs an APIPortalService. @@ -85,19 +86,31 @@ func NewAPIPortalService( orgRepo repository.OrganizationRepository, auditRepo repository.AuditRepository, secretVault vault.SecretVault, + authRegistry *APIPortalAuthRegistry, identity *IdentityService, slogger *slog.Logger, ) *APIPortalService { return &APIPortalService{ - portalRepo: portalRepo, - orgRepo: orgRepo, - auditRepo: auditRepo, - vault: secretVault, - identity: identity, - slogger: slogger, + portalRepo: portalRepo, + orgRepo: orgRepo, + auditRepo: auditRepo, + vault: secretVault, + authRegistry: authRegistry, + identity: identity, + slogger: slogger, } } +// invalidateCachedAuthProvider is a no-op when the service was constructed +// without a registry (e.g. in unit tests that don't need outbound auth). Keeps +// call sites clean of nil checks. +func (s *APIPortalService) invalidateCachedAuthProvider(portalHandle string) { + if s.authRegistry == nil { + return + } + s.authRegistry.Invalidate(portalHandle) +} + // validateAPIPortalAuthConfig enforces per-authType constraints on the config // map. For `local` the map must be empty; for `oauth2` all required keys must // be present and non-empty strings, and no unknown keys are allowed. @@ -485,6 +498,9 @@ func (s *APIPortalService) UpdateAPIPortal(handle string, req *UpdateAPIPortalRe return nil, err } _ = s.auditRepo.Record("UPDATE", portal.ID, "api_portal", orgID, portal.UpdatedBy) + // Config may have changed; drop any cached AuthProvider so the next + // outbound call rebuilds from the new stored values. + s.invalidateCachedAuthProvider(portal.Handle) return portal, nil } @@ -501,5 +517,6 @@ func (s *APIPortalService) DeleteAPIPortal(handle, orgID, actor string) error { return err } _ = s.auditRepo.Record("DELETE", portal.ID, "api_portal", orgID, strings.TrimSpace(actor)) + s.invalidateCachedAuthProvider(portal.Handle) return nil } diff --git a/platform-api/internal/service/api_portal_auth.go b/platform-api/internal/service/api_portal_auth.go new file mode 100644 index 0000000000..60ef82bdee --- /dev/null +++ b/platform-api/internal/service/api_portal_auth.go @@ -0,0 +1,319 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed 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. + * + */ + +// This file wires the outbound authentication path Platform-API uses when it +// calls a portal's admin REST endpoints. Two provider implementations sit +// behind one interface, and a small process-wide registry caches provider +// instances (and their token caches) per portal handle. +// +// Consumers (the publisher and any future portal-facing component) call +// APIPortalAuthRegistry.Get(portal).AuthorizationHeader(ctx) and get back a +// ready-to-use `Bearer ` string. Token refresh, caching, and mutex- +// guarded refresh are the provider's concern, not the caller's. + +package service + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" + + "github.com/wso2/api-platform/platform-api/config" + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/model" + "github.com/wso2/api-platform/platform-api/internal/vault" +) + +// AuthProvider is the outbound-auth surface exposed to any component that +// needs to call a portal's admin REST endpoints. The concrete provider +// (`local` or `oauth2`) is selected per portal by the registry; callers are +// unaware of which one they're holding. +type AuthProvider interface { + // AuthorizationHeader returns a valid "Bearer " header value for + // the next outbound call. The provider handles all caching + refresh + // internally — callers never see a stale token unless they invalidate + // explicitly. + AuthorizationHeader(ctx context.Context) (string, error) + + // InvalidateCache clears any cached token so the next call re-mints or + // re-fetches. Callers invoke this on a portal-side 401 to recover from + // an expired/revoked token the provider hasn't yet noticed. + InvalidateCache() +} + +// tokenCacheRefreshBuffer is the safety window subtracted from the STS-reported +// expiry so we refresh slightly before the token actually expires — otherwise +// a call that lands right at the expiry boundary would fail with a 401. +const tokenCacheRefreshBuffer = 30 * time.Second + +// localTokenTTL is the lifetime of a self-minted JWT for the `local` flow. +// Kept short so any key/config change on disk is picked up quickly on the +// next refresh; minting is cheap (single RS256 sign). +const localTokenTTL = 5 * time.Minute + +// --- LocalAuthProvider ----------------------------------------------------- + +// localAuthProvider mints platform-api-signed RS256 JWTs. The signing key is +// the same one AuthLoginHandler uses (Auth.JWT.PrivateKeyFile), so the +// devportal's `verifyBearerToken` in local mode — configured with the paired +// public key — accepts these tokens without any per-portal setup. +type localAuthProvider struct { + jwtCfg *config.JWT + + mu sync.Mutex + cached string + expiresAt time.Time +} + +func newLocalAuthProvider(jwtCfg *config.JWT) *localAuthProvider { + return &localAuthProvider{jwtCfg: jwtCfg} +} + +func (p *localAuthProvider) AuthorizationHeader(_ context.Context) (string, error) { + p.mu.Lock() + defer p.mu.Unlock() + if p.cached != "" && time.Now().Before(p.expiresAt.Add(-tokenCacheRefreshBuffer)) { + return p.cached, nil + } + + priv, err := p.jwtCfg.LoadPrivateKey() + if err != nil { + return "", fmt.Errorf("api-portal local auth: load private key: %w", err) + } + now := time.Now() + exp := now.Add(localTokenTTL) + claims := jwt.MapClaims{ + "sub": "platform-api-system", + "iss": "platform-api", + "roles": []string{"platform-api-system"}, + "iat": now.Unix(), + "exp": exp.Unix(), + } + signed, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(priv) + if err != nil { + return "", fmt.Errorf("api-portal local auth: sign token: %w", err) + } + p.cached = "Bearer " + signed + p.expiresAt = exp + return p.cached, nil +} + +func (p *localAuthProvider) InvalidateCache() { + p.mu.Lock() + defer p.mu.Unlock() + p.cached = "" + p.expiresAt = time.Time{} +} + +// --- ClientCredentialsAuthProvider ----------------------------------------- + +// clientCredentialsTokenResponse is the RFC 6749 §5.1 successful token +// response shape. Providers may include additional fields; we only read the +// two we need. +type clientCredentialsTokenResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` +} + +// clientCredentialsAuthProvider fetches a JWT from an external STS using the +// OAuth 2.0 client_credentials grant. Holds the plaintext client secret in +// memory for the lifetime of the provider; the caller retrieves that value by +// decrypting the stored ciphertext via the vault before constructing this. +type clientCredentialsAuthProvider struct { + tokenURL string + clientID string + clientSecret string + httpClient *http.Client + + mu sync.Mutex + cached string + expiresAt time.Time +} + +func newClientCredentialsAuthProvider(tokenURL, clientID, clientSecret string, hc *http.Client) *clientCredentialsAuthProvider { + if hc == nil { + hc = &http.Client{Timeout: 15 * time.Second} + } + return &clientCredentialsAuthProvider{ + tokenURL: tokenURL, + clientID: clientID, + clientSecret: clientSecret, + httpClient: hc, + } +} + +func (p *clientCredentialsAuthProvider) AuthorizationHeader(ctx context.Context) (string, error) { + p.mu.Lock() + defer p.mu.Unlock() + if p.cached != "" && time.Now().Before(p.expiresAt.Add(-tokenCacheRefreshBuffer)) { + return p.cached, nil + } + + form := url.Values{} + form.Set("grant_type", "client_credentials") + form.Set("client_id", p.clientID) + form.Set("client_secret", p.clientSecret) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.tokenURL, strings.NewReader(form.Encode())) + if err != nil { + return "", fmt.Errorf("api-portal oauth2 auth: build request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := p.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("api-portal oauth2 auth: token request: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("api-portal oauth2 auth: sts returned %d: %s", resp.StatusCode, string(body)) + } + var tok clientCredentialsTokenResponse + if err := json.Unmarshal(body, &tok); err != nil { + return "", fmt.Errorf("api-portal oauth2 auth: decode token response: %w", err) + } + if tok.AccessToken == "" { + return "", errors.New("api-portal oauth2 auth: sts response missing access_token") + } + // If expires_in is zero or missing, treat the token as short-lived so we + // refresh soon rather than caching for an unknown duration. + ttl := time.Duration(tok.ExpiresIn) * time.Second + if ttl <= 0 { + ttl = time.Minute + } + p.cached = "Bearer " + tok.AccessToken + p.expiresAt = time.Now().Add(ttl) + return p.cached, nil +} + +func (p *clientCredentialsAuthProvider) InvalidateCache() { + p.mu.Lock() + defer p.mu.Unlock() + p.cached = "" + p.expiresAt = time.Time{} +} + +// --- APIPortalAuthRegistry -------------------------------------------------- + +// APIPortalAuthRegistry is the process-wide cache of AuthProvider instances +// keyed by portal handle. Callers (publisher, health-check, anything else +// talking to a portal admin REST) share these instances so their token +// caches are hot across concurrent requests. Invalidate is called by the +// service layer on Update/Delete so cached providers reflect config changes. +type APIPortalAuthRegistry struct { + jwtCfg *config.JWT + secrets vault.SecretVault + httpClient *http.Client + + mu sync.Mutex + cache map[string]AuthProvider +} + +// NewAPIPortalAuthRegistry constructs the registry. `hc` may be nil, in which +// case each oauth2 provider gets a default http.Client with a 15s timeout. +func NewAPIPortalAuthRegistry(jwtCfg *config.JWT, secretVault vault.SecretVault, hc *http.Client) *APIPortalAuthRegistry { + return &APIPortalAuthRegistry{ + jwtCfg: jwtCfg, + secrets: secretVault, + httpClient: hc, + cache: make(map[string]AuthProvider), + } +} + +// Get returns the cached AuthProvider for a portal, constructing one from the +// stored row if none exists yet. Never returns a nil provider on success. +func (r *APIPortalAuthRegistry) Get(portal *model.APIPortal) (AuthProvider, error) { + if portal == nil { + return nil, errors.New("api-portal auth registry: portal is nil") + } + r.mu.Lock() + defer r.mu.Unlock() + if p, ok := r.cache[portal.Handle]; ok { + return p, nil + } + p, err := r.buildProvider(portal) + if err != nil { + return nil, err + } + r.cache[portal.Handle] = p + return p, nil +} + +// Invalidate evicts the cached provider for the given portal handle. Called by +// the service layer after a successful Update or Delete so the next Get +// picks up any config changes (or, for Delete, so we don't leak stale +// providers). +func (r *APIPortalAuthRegistry) Invalidate(portalHandle string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.cache, portalHandle) +} + +// buildProvider constructs the concrete provider from a stored portal row. +// For oauth2 the clientSecret is base64-decoded and decrypted via the vault +// here; the plaintext is then held in memory by the provider until the +// registry entry is invalidated. +func (r *APIPortalAuthRegistry) buildProvider(portal *model.APIPortal) (AuthProvider, error) { + switch portal.AuthType { + case constants.APIPortalAuthTypeLocal: + if r.jwtCfg == nil { + return nil, errors.New("api-portal auth registry: jwtCfg is nil; cannot mint local tokens") + } + return newLocalAuthProvider(r.jwtCfg), nil + case constants.APIPortalAuthTypeOAuth2: + if r.secrets == nil { + return nil, errors.New("api-portal auth registry: secret vault is nil; cannot decrypt oauth2 client secret") + } + tokenURL, _ := portal.AuthConfig[constants.APIPortalAuthConfigKeySTSTokenURL].(string) + clientID, _ := portal.AuthConfig[constants.APIPortalAuthConfigKeyClientID].(string) + encoded, _ := portal.AuthConfig[constants.APIPortalAuthConfigKeyClientSecret].(string) + if tokenURL == "" || clientID == "" || encoded == "" { + return nil, fmt.Errorf( + "api-portal auth registry: portal %q authConfig is missing required oauth2 fields", + portal.Handle) + } + ciphertext, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf( + "api-portal auth registry: portal %q clientSecret is not valid base64 ciphertext: %w", + portal.Handle, err) + } + plaintext, err := r.secrets.Decrypt(context.Background(), ciphertext) + if err != nil { + return nil, fmt.Errorf( + "api-portal auth registry: portal %q clientSecret decryption failed: %w", + portal.Handle, err) + } + return newClientCredentialsAuthProvider(tokenURL, clientID, plaintext, r.httpClient), nil + default: + return nil, fmt.Errorf( + "api-portal auth registry: portal %q has unsupported auth_type %q", + portal.Handle, portal.AuthType) + } +} diff --git a/platform-api/internal/service/api_portal_auth_test.go b/platform-api/internal/service/api_portal_auth_test.go new file mode 100644 index 0000000000..412b5f9960 --- /dev/null +++ b/platform-api/internal/service/api_portal_auth_test.go @@ -0,0 +1,381 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed 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 service + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + + "github.com/wso2/api-platform/platform-api/config" + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/model" +) + +// --- test helpers ------------------------------------------------------------ + +// newTestJWTConfig writes a fresh RSA private key to t.TempDir and returns a +// config.JWT pointing at it. Each test gets its own key so parallel runs stay +// isolated. +func newTestJWTConfig(t *testing.T) (*config.JWT, *rsa.PublicKey) { + t.Helper() + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate rsa: %v", err) + } + der, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + t.Fatalf("marshal pkcs8: %v", err) + } + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) + path := filepath.Join(t.TempDir(), "signing.pem") + if err := os.WriteFile(path, pemBytes, 0o600); err != nil { + t.Fatalf("write pem: %v", err) + } + return &config.JWT{PrivateKeyFile: path}, &priv.PublicKey +} + +// --- LocalAuthProvider tests ------------------------------------------------ + +func TestLocalAuthProvider_MintsVerifiableRS256(t *testing.T) { + jwtCfg, pub := newTestJWTConfig(t) + p := newLocalAuthProvider(jwtCfg) + + header, err := p.AuthorizationHeader(context.Background()) + if err != nil { + t.Fatalf("AuthorizationHeader: %v", err) + } + if !strings.HasPrefix(header, "Bearer ") { + t.Fatalf("expected Bearer prefix, got %q", header) + } + raw := strings.TrimPrefix(header, "Bearer ") + + parsed, err := jwt.Parse(raw, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + t.Fatalf("unexpected signing method: %v", token.Method) + } + return pub, nil + }) + if err != nil || !parsed.Valid { + t.Fatalf("token failed to verify: %v (valid=%v)", err, parsed != nil && parsed.Valid) + } + claims := parsed.Claims.(jwt.MapClaims) + if claims["sub"] != "platform-api-system" { + t.Errorf("sub: got %v", claims["sub"]) + } + if claims["iss"] != "platform-api" { + t.Errorf("iss: got %v", claims["iss"]) + } + rolesIface, ok := claims["roles"].([]interface{}) + if !ok || len(rolesIface) != 1 || rolesIface[0] != "platform-api-system" { + t.Errorf("roles claim: got %v", claims["roles"]) + } +} + +func TestLocalAuthProvider_CachesToken(t *testing.T) { + jwtCfg, _ := newTestJWTConfig(t) + p := newLocalAuthProvider(jwtCfg) + + h1, err := p.AuthorizationHeader(context.Background()) + if err != nil { + t.Fatalf("first mint: %v", err) + } + h2, err := p.AuthorizationHeader(context.Background()) + if err != nil { + t.Fatalf("second mint: %v", err) + } + if h1 != h2 { + t.Errorf("expected cached token to be reused; got %q vs %q", h1, h2) + } +} + +func TestLocalAuthProvider_InvalidateForcesRefresh(t *testing.T) { + jwtCfg, _ := newTestJWTConfig(t) + p := newLocalAuthProvider(jwtCfg) + + h1, err := p.AuthorizationHeader(context.Background()) + if err != nil { + t.Fatalf("first mint: %v", err) + } + // Sleep a moment so iat/exp claims differ between mints; RS256 signing is + // deterministic for identical inputs, so a same-second re-mint would + // produce the same signature and defeat the assertion. + time.Sleep(time.Second + 100*time.Millisecond) + p.InvalidateCache() + h2, err := p.AuthorizationHeader(context.Background()) + if err != nil { + t.Fatalf("post-invalidate mint: %v", err) + } + if h1 == h2 { + t.Errorf("expected fresh token after Invalidate; got same value") + } +} + +// --- ClientCredentialsAuthProvider tests ------------------------------------ + +// stsStub is a minimal STS token endpoint used to verify the request body and +// return a canned token response. Counts requests so tests can assert caching +// behaviour. +type stsStub struct { + server *httptest.Server + calls int32 + nextToken string + nextTTL int + nextStatus int + lastForm string +} + +func newSTSStub() *stsStub { + s := &stsStub{nextToken: "tok-1", nextTTL: 3600, nextStatus: 200} + s.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&s.calls, 1) + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + s.lastForm = r.Form.Encode() + if s.nextStatus < 200 || s.nextStatus >= 300 { + w.WriteHeader(s.nextStatus) + _, _ = w.Write([]byte(`{"error":"invalid_client"}`)) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(clientCredentialsTokenResponse{ + AccessToken: s.nextToken, + ExpiresIn: s.nextTTL, + }) + })) + return s +} + +func (s *stsStub) URL() string { return s.server.URL } +func (s *stsStub) Calls() int { return int(atomic.LoadInt32(&s.calls)) } +func (s *stsStub) Close() { s.server.Close() } + +func TestClientCredentialsAuthProvider_FetchesAndFormatsHeader(t *testing.T) { + sts := newSTSStub() + t.Cleanup(sts.Close) + sts.nextToken = "abc.def.ghi" + + p := newClientCredentialsAuthProvider(sts.URL(), "client-id", "client-secret", nil) + header, err := p.AuthorizationHeader(context.Background()) + if err != nil { + t.Fatalf("AuthorizationHeader: %v", err) + } + if header != "Bearer abc.def.ghi" { + t.Errorf("header: got %q, want %q", header, "Bearer abc.def.ghi") + } + // Assert the request body carries the expected grant params. + if !strings.Contains(sts.lastForm, "grant_type=client_credentials") || + !strings.Contains(sts.lastForm, "client_id=client-id") || + !strings.Contains(sts.lastForm, "client_secret=client-secret") { + t.Errorf("STS form fields wrong: %q", sts.lastForm) + } +} + +func TestClientCredentialsAuthProvider_CachesUntilNearExpiry(t *testing.T) { + sts := newSTSStub() + t.Cleanup(sts.Close) + + p := newClientCredentialsAuthProvider(sts.URL(), "id", "secret", nil) + for i := 0; i < 3; i++ { + if _, err := p.AuthorizationHeader(context.Background()); err != nil { + t.Fatalf("call %d: %v", i, err) + } + } + if sts.Calls() != 1 { + t.Errorf("expected 1 STS call (cache hit for the rest); got %d", sts.Calls()) + } +} + +func TestClientCredentialsAuthProvider_InvalidateForcesRefetch(t *testing.T) { + sts := newSTSStub() + t.Cleanup(sts.Close) + + p := newClientCredentialsAuthProvider(sts.URL(), "id", "secret", nil) + if _, err := p.AuthorizationHeader(context.Background()); err != nil { + t.Fatal(err) + } + p.InvalidateCache() + if _, err := p.AuthorizationHeader(context.Background()); err != nil { + t.Fatal(err) + } + if sts.Calls() != 2 { + t.Errorf("expected 2 STS calls after invalidate; got %d", sts.Calls()) + } +} + +func TestClientCredentialsAuthProvider_NonSuccessStatusReturnsError(t *testing.T) { + sts := newSTSStub() + t.Cleanup(sts.Close) + sts.nextStatus = 401 + + p := newClientCredentialsAuthProvider(sts.URL(), "id", "wrong-secret", nil) + _, err := p.AuthorizationHeader(context.Background()) + if err == nil { + t.Fatal("expected error for 401 from STS") + } + if !strings.Contains(err.Error(), "401") { + t.Errorf("error should surface the status code; got %v", err) + } +} + +func TestClientCredentialsAuthProvider_ConcurrentCallsIssueSingleFetch(t *testing.T) { + sts := newSTSStub() + t.Cleanup(sts.Close) + + p := newClientCredentialsAuthProvider(sts.URL(), "id", "secret", nil) + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, _ = p.AuthorizationHeader(context.Background()) + }() + } + wg.Wait() + if sts.Calls() != 1 { + t.Errorf("thundering-herd guard: expected 1 STS call, got %d", sts.Calls()) + } +} + +// --- APIPortalAuthRegistry tests -------------------------------------------- + +func TestAPIPortalAuthRegistry_LocalRoundTrip(t *testing.T) { + jwtCfg, _ := newTestJWTConfig(t) + reg := NewAPIPortalAuthRegistry(jwtCfg, newTestVault(t), nil) + portal := &model.APIPortal{Handle: "acme", AuthType: constants.APIPortalAuthTypeLocal} + p, err := reg.Get(portal) + if err != nil { + t.Fatalf("Get: %v", err) + } + if _, err := p.AuthorizationHeader(context.Background()); err != nil { + t.Fatalf("AuthorizationHeader: %v", err) + } +} + +func TestAPIPortalAuthRegistry_OAuth2DecryptsSecret(t *testing.T) { + v := newTestVault(t) + sts := newSTSStub() + t.Cleanup(sts.Close) + + // Encrypt the plaintext secret the same way the service does on write. + ciphertext, err := v.Encrypt(context.Background(), "s3cr3t") + if err != nil { + t.Fatalf("encrypt: %v", err) + } + encoded := base64.StdEncoding.EncodeToString(ciphertext) + + reg := NewAPIPortalAuthRegistry(nil, v, nil) + portal := &model.APIPortal{ + Handle: "acme", + AuthType: constants.APIPortalAuthTypeOAuth2, + AuthConfig: map[string]interface{}{ + constants.APIPortalAuthConfigKeySTSTokenURL: sts.URL(), + constants.APIPortalAuthConfigKeyClientID: "cid", + constants.APIPortalAuthConfigKeyClientSecret: encoded, + }, + } + p, err := reg.Get(portal) + if err != nil { + t.Fatalf("Get: %v", err) + } + if _, err := p.AuthorizationHeader(context.Background()); err != nil { + t.Fatalf("AuthorizationHeader: %v", err) + } + // The provider should have sent the decrypted plaintext to the STS. + if !strings.Contains(sts.lastForm, "client_secret=s3cr3t") { + t.Errorf("provider did not decrypt clientSecret before sending; STS form: %q", sts.lastForm) + } +} + +func TestAPIPortalAuthRegistry_GetReturnsSameInstance(t *testing.T) { + jwtCfg, _ := newTestJWTConfig(t) + reg := NewAPIPortalAuthRegistry(jwtCfg, newTestVault(t), nil) + portal := &model.APIPortal{Handle: "acme", AuthType: constants.APIPortalAuthTypeLocal} + a, err := reg.Get(portal) + if err != nil { + t.Fatal(err) + } + b, err := reg.Get(portal) + if err != nil { + t.Fatal(err) + } + if a != b { + t.Errorf("expected same cached instance across calls") + } +} + +func TestAPIPortalAuthRegistry_InvalidateEvicts(t *testing.T) { + jwtCfg, _ := newTestJWTConfig(t) + reg := NewAPIPortalAuthRegistry(jwtCfg, newTestVault(t), nil) + portal := &model.APIPortal{Handle: "acme", AuthType: constants.APIPortalAuthTypeLocal} + a, _ := reg.Get(portal) + reg.Invalidate("acme") + b, _ := reg.Get(portal) + if a == b { + t.Errorf("expected a fresh instance after Invalidate") + } +} + +func TestAPIPortalAuthRegistry_OAuth2MissingFieldsFails(t *testing.T) { + reg := NewAPIPortalAuthRegistry(nil, newTestVault(t), nil) + portal := &model.APIPortal{ + Handle: "acme", + AuthType: constants.APIPortalAuthTypeOAuth2, + AuthConfig: map[string]interface{}{ + // stsTokenUrl missing. + constants.APIPortalAuthConfigKeyClientID: "cid", + constants.APIPortalAuthConfigKeyClientSecret: "not-really-encrypted", + }, + } + if _, err := reg.Get(portal); err == nil { + t.Fatal("expected error for missing oauth2 authConfig fields") + } +} + +func TestAPIPortalAuthRegistry_OAuth2BadCiphertextFails(t *testing.T) { + reg := NewAPIPortalAuthRegistry(nil, newTestVault(t), nil) + portal := &model.APIPortal{ + Handle: "acme", + AuthType: constants.APIPortalAuthTypeOAuth2, + AuthConfig: map[string]interface{}{ + constants.APIPortalAuthConfigKeySTSTokenURL: "https://sts", + constants.APIPortalAuthConfigKeyClientID: "cid", + constants.APIPortalAuthConfigKeyClientSecret: "!!!not-base64!!!", + }, + } + if _, err := reg.Get(portal); err == nil { + t.Fatal("expected error for non-base64 clientSecret") + } +} diff --git a/platform-api/internal/service/api_portal_test.go b/platform-api/internal/service/api_portal_test.go index 8503a8ec3c..fb57f2de46 100644 --- a/platform-api/internal/service/api_portal_test.go +++ b/platform-api/internal/service/api_portal_test.go @@ -144,7 +144,7 @@ func newTestAPIPortalService(t *testing.T, orgRepo repository.OrganizationRepository, auditRepo repository.AuditRepository, ) *APIPortalService { - return NewAPIPortalService(portalRepo, orgRepo, auditRepo, newTestVault(t), nil, nil) + return NewAPIPortalService(portalRepo, orgRepo, auditRepo, newTestVault(t), nil, nil, nil) } func apiPortalStrPtr(s string) *string { return &s } From 53273f7360225d356e04375580ea8ffb46a505bd Mon Sep 17 00:00:00 2001 From: dushaniw Date: Mon, 17 Aug 2026 18:11:35 +0530 Subject: [PATCH 12/25] Clear stored authConfig when switching authType to local If a portal was created as `oauth2` and a subsequent PUT changes `authType` to `local`, the stored `authConfig` retained the oauth2 keys (stsTokenUrl, clientId, clientSecret). The post-mutation `validateAPIPortalAuthConfig` then rejected the request because `local` requires an empty map, and no wire body could satisfy the transition (JSON can't distinguish "field absent" from "field explicitly null" for a map through the generated DTO). Fix: after applying all mutations, if the effective authType is `local`, drop portal.AuthConfig to nil before the re-validation. Regression test asserts the stored authConfig is cleared when an existing oauth2 portal is switched to local. Co-Authored-By: Claude Opus 4.7 (1M context) --- platform-api/internal/service/api_portal.go | 8 +++++ .../internal/service/api_portal_test.go | 34 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/platform-api/internal/service/api_portal.go b/platform-api/internal/service/api_portal.go index 43d3e7d3c6..42372732e3 100644 --- a/platform-api/internal/service/api_portal.go +++ b/platform-api/internal/service/api_portal.go @@ -480,6 +480,14 @@ func (s *APIPortalService) UpdateAPIPortal(handle string, req *UpdateAPIPortalRe // Metadata is opaque pass-through; supplied map fully replaces stored. portal.Metadata = copyStringMap(req.Metadata) } + // authType owns the shape of authConfig. When the effective type is `local`, + // authConfig keys carried over from a previous `oauth2` configuration are + // dropped rather than left to fail a validation the caller cannot satisfy + // (they can't send authConfig=null on the wire to clear it while nil-vs- + // absent are the same shape in JSON). + if portal.AuthType == constants.APIPortalAuthTypeLocal { + portal.AuthConfig = nil + } // Re-validate authConfig against the effective authType after all mutations. if err := validateAPIPortalAuthConfig(portal.AuthType, portal.AuthConfig); err != nil { return nil, err diff --git a/platform-api/internal/service/api_portal_test.go b/platform-api/internal/service/api_portal_test.go index fb57f2de46..e7b1ada59b 100644 --- a/platform-api/internal/service/api_portal_test.go +++ b/platform-api/internal/service/api_portal_test.go @@ -471,6 +471,40 @@ func TestAPIPortalService_UpdateAPIPortal_ActivateWithNewURL(t *testing.T) { } } +func TestAPIPortalService_UpdateAPIPortal_SwitchOAuth2ToLocal(t *testing.T) { + // Regression: switching authType from oauth2 to local must clear the stored + // oauth2 authConfig — otherwise the post-mutation validator rejects the + // carried-over keys and no wire body can satisfy the request. + existing := &model.APIPortal{ + ID: "p1", Handle: "acme", OrganizationID: "org-1", + Name: "Acme", URL: "https://acme.example.com", + WorkflowStatus: constants.APIPortalWorkflowStatusActive, + AuthType: constants.APIPortalAuthTypeOAuth2, + AuthConfig: map[string]interface{}{ + "stsTokenUrl": "https://sts.example.com/token", + "clientId": "abc", + "clientSecret": "already-ciphertext-base64", + }, + } + svc := newTestAPIPortalService(t, + &mockAPIPortalRepository{getResult: existing}, + &mockAPIPortalOrgRepository{}, + &mockAPIPortalAuditRepository{}, + ) + got, err := svc.UpdateAPIPortal("acme", &UpdateAPIPortalRequest{ + AuthType: apiPortalStrPtr(constants.APIPortalAuthTypeLocal), + }, "org-1", "editor") + if err != nil { + t.Fatalf("switch oauth2 → local: %v", err) + } + if got.AuthType != constants.APIPortalAuthTypeLocal { + t.Errorf("authType not applied: %q", got.AuthType) + } + if len(got.AuthConfig) != 0 { + t.Errorf("stored authConfig not cleared on transition to local: %+v", got.AuthConfig) + } +} + func TestAPIPortalService_UpdateAPIPortal_InvalidURLRejected(t *testing.T) { existing := &model.APIPortal{ ID: "p1", Handle: "acme", OrganizationID: "org-1", From b1338ad766810298e73e539112923b4eba8e7006 Mon Sep 17 00:00:00 2001 From: dushaniw Date: Tue, 18 Aug 2026 10:11:51 +0530 Subject: [PATCH 13/25] fix duplicate tag. --- platform-api/resources/openapi.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index e0bba81948..2f7d17876c 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -9448,10 +9448,8 @@ tags: description: API management operations - name: REST API Deployments description: API deployment artifact management and lifecycle operations - - name: API Portal - description: API portal publishing and unpublishing operations - name: API Portals - description: API Portal registration and management (CRUD on /api-portals) + description: API Portal registration and management - name: DevPortals description: DevPortal management operations - name: Gateways From 86f1d2e52c35a598dd2c958e1973594693da12ac Mon Sep 17 00:00:00 2001 From: dushaniw Date: Tue, 18 Aug 2026 10:12:27 +0530 Subject: [PATCH 14/25] remove devportals tag. --- platform-api/resources/openapi.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index 2f7d17876c..8334e0fd37 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -9450,8 +9450,6 @@ tags: description: API deployment artifact management and lifecycle operations - name: API Portals description: API Portal registration and management - - name: DevPortals - description: DevPortal management operations - name: Gateways description: Gateway registration and management operations - name: Gateway Tokens From 949ce62d59823fbeda6ed082db1eba6867b44342 Mon Sep 17 00:00:00 2001 From: dushaniw Date: Tue, 25 Aug 2026 14:40:32 +0530 Subject: [PATCH 15/25] Remove workflowStatus from the /api-portals wire surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OSS registers a portal that's already running, so every OSS row is created as active from day one. Exposing workflowStatus as a mutable field over the wire suggested a lifecycle clients don't drive — remove it from the OpenAPI, keep the DB column for future extensibility. Concrete changes: - openapi.yaml: workflowStatus dropped from ApiPortalResponse, ApiPortalListItem, CreateApiPortalRequest, UpdateApiPortalRequest, the ListApiPortals query filter reference, and the parameter definition. `url` is now required on the create request (the "may be null while pending" story no longer applies to OSS). - api/generated.go: regenerated from the updated spec — none of the ApiPortal shapes carry WorkflowStatus anymore. - service/api_portal.go: dropped the workflowStatus validation, the "active requires URL" cross-field check, and the workflowStatus filter in ListAPIPortals. Server unconditionally sets WorkflowStatus = APIPortalWorkflowStatusActive on create; UPDATE can't touch it. URL is now required on create. - repository/api_portal.go + interfaces.go: ListPaginated and Count drop their workflowStatus filter parameter. - handler/api_portal.go: create/update/list stop reading or writing the field; response and list-item mappings no longer emit it. - constants/constants.go: retire ValidAPIPortalWorkflowStatuses and ValidAPIPortalCreateWorkflowStatuses. Keep the three status constants — the DB column still exists and future work may need them. - Tests: dropped workflowStatus-specific create/update/list cases (invalid-status, active-without-url, filter-by-status, activate-with-new-url). The happy-path create test now asserts the server always persists workflow_status=active. Co-Authored-By: Claude Opus 4.7 (1M context) --- platform-api/api/generated.go | 110 ++--------- platform-api/internal/constants/constants.go | 21 +- platform-api/internal/handler/api_portal.go | 46 ++--- .../handler/api_portal_integration_test.go | 133 +++++-------- .../internal/repository/api_portal.go | 17 +- .../internal/repository/api_portal_test.go | 52 +---- .../internal/repository/interfaces.go | 11 +- platform-api/internal/service/api_portal.go | 80 +++----- .../internal/service/api_portal_test.go | 180 ++---------------- platform-api/resources/openapi.yaml | 51 +---- 10 files changed, 146 insertions(+), 555 deletions(-) diff --git a/platform-api/api/generated.go b/platform-api/api/generated.go index f580f2b511..04341ec693 100644 --- a/platform-api/api/generated.go +++ b/platform-api/api/generated.go @@ -35,26 +35,12 @@ const ( ApiPortalListItemAuthTypeOauth2 ApiPortalListItemAuthType = "oauth2" ) -// Defines values for ApiPortalListItemWorkflowStatus. -const ( - ApiPortalListItemWorkflowStatusActive ApiPortalListItemWorkflowStatus = "active" - ApiPortalListItemWorkflowStatusFailed ApiPortalListItemWorkflowStatus = "failed" - ApiPortalListItemWorkflowStatusPending ApiPortalListItemWorkflowStatus = "pending" -) - // Defines values for ApiPortalResponseAuthType. const ( ApiPortalResponseAuthTypeLocal ApiPortalResponseAuthType = "local" ApiPortalResponseAuthTypeOauth2 ApiPortalResponseAuthType = "oauth2" ) -// Defines values for ApiPortalResponseWorkflowStatus. -const ( - ApiPortalResponseWorkflowStatusActive ApiPortalResponseWorkflowStatus = "active" - ApiPortalResponseWorkflowStatusFailed ApiPortalResponseWorkflowStatus = "failed" - ApiPortalResponseWorkflowStatusPending ApiPortalResponseWorkflowStatus = "pending" -) - // Defines values for ApplicationAssociationSelectorKind. const ( ApplicationAssociationSelectorKindLlmProvider ApplicationAssociationSelectorKind = "LlmProvider" @@ -83,12 +69,6 @@ const ( CreateApiPortalRequestAuthTypeOauth2 CreateApiPortalRequestAuthType = "oauth2" ) -// Defines values for CreateApiPortalRequestWorkflowStatus. -const ( - CreateApiPortalRequestWorkflowStatusActive CreateApiPortalRequestWorkflowStatus = "active" - CreateApiPortalRequestWorkflowStatusPending CreateApiPortalRequestWorkflowStatus = "pending" -) - // Defines values for CreateGatewayRequestFunctionalityType. const ( CreateGatewayRequestFunctionalityTypeAi CreateGatewayRequestFunctionalityType = "ai" @@ -197,9 +177,9 @@ const ( // Defines values for MCPProxyListItemStatus. const ( - MCPProxyListItemStatusDeployed MCPProxyListItemStatus = "deployed" - MCPProxyListItemStatusFailed MCPProxyListItemStatus = "failed" - MCPProxyListItemStatusPending MCPProxyListItemStatus = "pending" + Deployed MCPProxyListItemStatus = "deployed" + Failed MCPProxyListItemStatus = "failed" + Pending MCPProxyListItemStatus = "pending" ) // Defines values for OperationPolicyPathMethods. @@ -357,13 +337,6 @@ const ( UpdateApiPortalRequestAuthTypeOauth2 UpdateApiPortalRequestAuthType = "oauth2" ) -// Defines values for UpdateApiPortalRequestWorkflowStatus. -const ( - UpdateApiPortalRequestWorkflowStatusActive UpdateApiPortalRequestWorkflowStatus = "active" - UpdateApiPortalRequestWorkflowStatusFailed UpdateApiPortalRequestWorkflowStatus = "failed" - UpdateApiPortalRequestWorkflowStatusPending UpdateApiPortalRequestWorkflowStatus = "pending" -) - // Defines values for UpstreamAuthType. const ( ApiKey UpstreamAuthType = "api-key" @@ -382,16 +355,9 @@ const ( // Defines values for UserAPIKeyItemStatus. const ( - UserAPIKeyItemStatusActive UserAPIKeyItemStatus = "active" - UserAPIKeyItemStatusExpired UserAPIKeyItemStatus = "expired" - UserAPIKeyItemStatusRevoked UserAPIKeyItemStatus = "revoked" -) - -// Defines values for ApiPortalWorkflowStatusQ. -const ( - ApiPortalWorkflowStatusQActive ApiPortalWorkflowStatusQ = "active" - ApiPortalWorkflowStatusQFailed ApiPortalWorkflowStatusQ = "failed" - ApiPortalWorkflowStatusQPending ApiPortalWorkflowStatusQ = "pending" + Active UserAPIKeyItemStatus = "active" + Expired UserAPIKeyItemStatus = "expired" + Revoked UserAPIKeyItemStatus = "revoked" ) // Defines values for DeploymentStatusQ. @@ -428,13 +394,6 @@ const ( ListApiPortalsParamsSortOrderDesc ListApiPortalsParamsSortOrder = "desc" ) -// Defines values for ListApiPortalsParamsWorkflowStatus. -const ( - ListApiPortalsParamsWorkflowStatusActive ListApiPortalsParamsWorkflowStatus = "active" - ListApiPortalsParamsWorkflowStatusFailed ListApiPortalsParamsWorkflowStatus = "failed" - ListApiPortalsParamsWorkflowStatusPending ListApiPortalsParamsWorkflowStatus = "pending" -) - // Defines values for ListApplicationsParamsSortBy. const ( ListApplicationsParamsSortByCreatedAt ListApplicationsParamsSortBy = "createdAt" @@ -643,22 +602,18 @@ type ApiPortalAuthConfig struct { // ApiPortalListItem Lightweight projection returned in collection responses (excludes the `config` blob). type ApiPortalListItem struct { - AuthType ApiPortalListItemAuthType `binding:"required" json:"authType" yaml:"authType"` - CreatedAt time.Time `binding:"required" json:"createdAt" yaml:"createdAt"` - Description *string `json:"description" yaml:"description"` - Handle string `binding:"required" json:"handle" yaml:"handle"` - Id string `binding:"required" json:"id" yaml:"id"` - Name string `binding:"required" json:"name" yaml:"name"` - Url *string `json:"url" yaml:"url"` - WorkflowStatus ApiPortalListItemWorkflowStatus `binding:"required" json:"workflowStatus" yaml:"workflowStatus"` + AuthType ApiPortalListItemAuthType `binding:"required" json:"authType" yaml:"authType"` + CreatedAt time.Time `binding:"required" json:"createdAt" yaml:"createdAt"` + Description *string `json:"description" yaml:"description"` + Handle string `binding:"required" json:"handle" yaml:"handle"` + Id string `binding:"required" json:"id" yaml:"id"` + Name string `binding:"required" json:"name" yaml:"name"` + Url string `binding:"required" json:"url" yaml:"url"` } // ApiPortalListItemAuthType defines model for ApiPortalListItem.AuthType. type ApiPortalListItemAuthType string -// ApiPortalListItemWorkflowStatus defines model for ApiPortalListItem.WorkflowStatus. -type ApiPortalListItemWorkflowStatus string - // ApiPortalListResponse defines model for ApiPortalListResponse. type ApiPortalListResponse struct { // Count Number of items in the current response page. @@ -698,19 +653,13 @@ type ApiPortalResponse struct { Name string `binding:"required" json:"name" yaml:"name"` UpdatedAt *time.Time `binding:"required" json:"updatedAt,omitempty" yaml:"updatedAt,omitempty"` - // Url Public URL of the API Portal. May be null while the portal is being provisioned; populated once the instance is reachable. - Url *string `json:"url" yaml:"url"` - - // WorkflowStatus Lifecycle state. `pending` — portal is being provisioned or activated. `active` — portal is reachable and functional. `failed` — provisioning or a subsequent health check failed. - WorkflowStatus ApiPortalResponseWorkflowStatus `binding:"required" json:"workflowStatus" yaml:"workflowStatus"` + // Url Public URL of the API Portal. Operator-supplied. + Url string `binding:"required" json:"url" yaml:"url"` } // ApiPortalResponseAuthType Determines how Platform API authenticates to the portal's admin API and selects the shape of the `config` object. type ApiPortalResponseAuthType string -// ApiPortalResponseWorkflowStatus Lifecycle state. `pending` — portal is being provisioned or activated. `active` — portal is reachable and functional. `failed` — provisioning or a subsequent health check failed. -type ApiPortalResponseWorkflowStatus string - // Application defines model for Application. type Application struct { CreatedAt *time.Time `json:"createdAt,omitempty" yaml:"createdAt,omitempty"` @@ -902,19 +851,13 @@ type CreateApiPortalRequest struct { Metadata *ApiPortalMetadata `json:"metadata,omitempty" yaml:"metadata,omitempty"` Name string `binding:"required" json:"name" yaml:"name"` - // Url Public URL of an existing API Portal to register. Omit to have a new portal provisioned; the URL will be populated once the instance is reachable. - Url *string `json:"url" yaml:"url"` - - // WorkflowStatus Optional. Defaults to `pending` when omitted. Setting `active` requires a non-empty `url`; the request is rejected otherwise. `failed` cannot be set on create — a portal is never created in a failed state. - WorkflowStatus *CreateApiPortalRequestWorkflowStatus `json:"workflowStatus,omitempty" yaml:"workflowStatus,omitempty"` + // Url Public URL of the API Portal to register. Operator-supplied. + Url string `binding:"required" json:"url" yaml:"url"` } // CreateApiPortalRequestAuthType defines model for CreateApiPortalRequest.AuthType. type CreateApiPortalRequestAuthType string -// CreateApiPortalRequestWorkflowStatus Optional. Defaults to `pending` when omitted. Setting `active` requires a non-empty `url`; the request is rejected otherwise. `failed` cannot be set on create — a portal is never created in a failed state. -type CreateApiPortalRequestWorkflowStatus string - // CreateApplicationRequest Request body for creating an application. type CreateApplicationRequest struct { // Description Description of the application @@ -2759,18 +2702,14 @@ type UpdateApiPortalRequest struct { Description *string `json:"description" yaml:"description"` // Metadata Free-form pass-through metadata for the portal pod (e.g. cloud-side OIDC endpoints the portal uses for consumer login). Platform-API stores and returns this as-is; it is not consumed by the outbound authentication path. - Metadata *ApiPortalMetadata `json:"metadata,omitempty" yaml:"metadata,omitempty"` - Name *string `json:"name,omitempty" yaml:"name,omitempty"` - Url *string `json:"url" yaml:"url"` - WorkflowStatus *UpdateApiPortalRequestWorkflowStatus `json:"workflowStatus,omitempty" yaml:"workflowStatus,omitempty"` + Metadata *ApiPortalMetadata `json:"metadata,omitempty" yaml:"metadata,omitempty"` + Name *string `json:"name,omitempty" yaml:"name,omitempty"` + Url *string `json:"url,omitempty" yaml:"url,omitempty"` } // UpdateApiPortalRequestAuthType defines model for UpdateApiPortalRequest.AuthType. type UpdateApiPortalRequestAuthType string -// UpdateApiPortalRequestWorkflowStatus defines model for UpdateApiPortalRequest.WorkflowStatus. -type UpdateApiPortalRequestWorkflowStatus string - // Upstream Upstream backend configuration with main and sandbox endpoints type Upstream struct { // Main Upstream endpoint configuration. Provide exactly one of `url` (a direct backend URL) or @@ -2878,9 +2817,6 @@ type ApiId = string // ApiPortalId defines model for apiPortalId. type ApiPortalId = string -// ApiPortalWorkflowStatusQ defines model for apiPortalWorkflowStatus-Q. -type ApiPortalWorkflowStatusQ string - // AppId defines model for appId. type AppId = string @@ -2972,9 +2908,6 @@ type ListApiPortalsParams struct { // Query Case-insensitive substring filter matched against the resource id (handle). Query *QueryQ `form:"query,omitempty" json:"query,omitempty" yaml:"query,omitempty"` - - // WorkflowStatus Filter API Portals by lifecycle state. - WorkflowStatus *ListApiPortalsParamsWorkflowStatus `form:"workflowStatus,omitempty" json:"workflowStatus,omitempty" yaml:"workflowStatus,omitempty"` } // ListApiPortalsParamsSortBy defines parameters for ListApiPortals. @@ -2983,9 +2916,6 @@ type ListApiPortalsParamsSortBy string // ListApiPortalsParamsSortOrder defines parameters for ListApiPortals. type ListApiPortalsParamsSortOrder string -// ListApiPortalsParamsWorkflowStatus defines parameters for ListApiPortals. -type ListApiPortalsParamsWorkflowStatus string - // ListApplicationsParams defines parameters for ListApplications. type ListApplicationsParams struct { // ProjectId **Project ID** consisting of the **handle** (unique slug identifier) of the Project whose resources should be returned. diff --git a/platform-api/internal/constants/constants.go b/platform-api/internal/constants/constants.go index 3f03367c40..18f042643a 100644 --- a/platform-api/internal/constants/constants.go +++ b/platform-api/internal/constants/constants.go @@ -207,29 +207,16 @@ var ValidGatewayTokenStatuses = map[string]bool{ GatewayTokenStatusRevoked: true, } -// API Portal workflow status constants +// API Portal workflow status constants. The column exists on api_portals for +// future extensibility but is not surfaced on the wire in the OSS offering: +// OSS registers a portal that's already running, so every OSS row is created +// as APIPortalWorkflowStatusActive and never mutated by clients. const ( APIPortalWorkflowStatusPending = "pending" APIPortalWorkflowStatusActive = "active" APIPortalWorkflowStatusFailed = "failed" ) -// ValidAPIPortalWorkflowStatuses holds accepted values for api_portals.workflow_status -var ValidAPIPortalWorkflowStatuses = map[string]bool{ - APIPortalWorkflowStatusPending: true, - APIPortalWorkflowStatusActive: true, - APIPortalWorkflowStatusFailed: true, -} - -// ValidAPIPortalCreateWorkflowStatuses holds accepted values for workflow_status -// at Create time. `failed` is intentionally excluded — a portal is never -// created in a failed state; that state is only reachable via a subsequent -// update once provisioning or a health check reports failure. -var ValidAPIPortalCreateWorkflowStatuses = map[string]bool{ - APIPortalWorkflowStatusPending: true, - APIPortalWorkflowStatusActive: true, -} - // API Portal authConfig field-name constants used by Create/Update validation // (required-field check) and by ClientCredentialsAuthProvider (payload build). const ( diff --git a/platform-api/internal/handler/api_portal.go b/platform-api/internal/handler/api_portal.go index 78b796bead..f1fc1314da 100644 --- a/platform-api/internal/handler/api_portal.go +++ b/platform-api/internal/handler/api_portal.go @@ -70,14 +70,11 @@ func (h *APIPortalHandler) CreateAPIPortal(w http.ResponseWriter, r *http.Reques Handle: strings.TrimSpace(req.Handle), Name: strings.TrimSpace(req.Name), Description: deref(req.Description), - URL: deref(req.Url), + URL: req.Url, AuthType: string(req.AuthType), AuthConfig: authConfigStructToMap(req.AuthConfig), Metadata: derefMetadata(req.Metadata), } - if req.WorkflowStatus != nil { - svcReq.WorkflowStatus = string(*req.WorkflowStatus) - } portal, err := h.svc.CreateAPIPortal(svcReq, orgID, createdBy) if err != nil { return serviceError(err, fmt.Sprintf("failed to create api portal %q for org %s by user %s", svcReq.Handle, orgID, createdBy)) @@ -116,9 +113,6 @@ func (h *APIPortalHandler) ListAPIPortals(w http.ResponseWriter, r *http.Request } opts := service.APIPortalListOptions{ListOptions: parseListOptions(r)} - if ws := strings.TrimSpace(r.URL.Query().Get("workflowStatus")); ws != "" { - opts.WorkflowStatus = &ws - } resp, err := h.svc.ListAPIPortals(orgID, opts) if err != nil { @@ -157,10 +151,6 @@ func (h *APIPortalHandler) UpdateAPIPortal(w http.ResponseWriter, r *http.Reques AuthConfig: authConfigStructToMap(req.AuthConfig), Metadata: derefMetadata(req.Metadata), } - if req.WorkflowStatus != nil { - v := string(*req.WorkflowStatus) - svcReq.WorkflowStatus = &v - } if req.AuthType != nil { v := string(*req.AuthType) svcReq.AuthType = &v @@ -298,22 +288,18 @@ func modelToAPIPortalResponse(p *model.APIPortal) *api.ApiPortalResponse { updatedAt := p.UpdatedAt resp := &api.ApiPortalResponse{ - Id: &id, - Handle: &handle, - Name: p.Name, - AuthType: api.ApiPortalResponseAuthType(p.AuthType), - WorkflowStatus: api.ApiPortalResponseWorkflowStatus(p.WorkflowStatus), - CreatedAt: &createdAt, - UpdatedAt: &updatedAt, + Id: &id, + Handle: &handle, + Name: p.Name, + Url: p.URL, + AuthType: api.ApiPortalResponseAuthType(p.AuthType), + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, } if p.Description != "" { desc := p.Description resp.Description = &desc } - if p.URL != "" { - url := p.URL - resp.Url = &url - } if p.AuthConfig != nil { resp.AuthConfig = mapToAuthConfigStruct(p.AuthConfig) } @@ -326,21 +312,17 @@ func modelToAPIPortalResponse(p *model.APIPortal) *api.ApiPortalResponse { func modelToAPIPortalListItem(p *model.APIPortal) api.ApiPortalListItem { item := api.ApiPortalListItem{ - Id: p.Handle, - Handle: p.Handle, - Name: p.Name, - AuthType: api.ApiPortalListItemAuthType(p.AuthType), - WorkflowStatus: api.ApiPortalListItemWorkflowStatus(p.WorkflowStatus), - CreatedAt: p.CreatedAt, + Id: p.Handle, + Handle: p.Handle, + Name: p.Name, + Url: p.URL, + AuthType: api.ApiPortalListItemAuthType(p.AuthType), + CreatedAt: p.CreatedAt, } if p.Description != "" { desc := p.Description item.Description = &desc } - if p.URL != "" { - url := p.URL - item.Url = &url - } return item } diff --git a/platform-api/internal/handler/api_portal_integration_test.go b/platform-api/internal/handler/api_portal_integration_test.go index c267f8aac9..78f9d60a09 100644 --- a/platform-api/internal/handler/api_portal_integration_test.go +++ b/platform-api/internal/handler/api_portal_integration_test.go @@ -120,15 +120,14 @@ func mustJSON(t *testing.T, v any) []byte { // Minimal response shapes for decoding — mirror the fields the handler emits. // Using a dedicated local shape avoids the pointer maze of api.ApiPortalResponse. type apiPortalResp struct { - Id string `json:"id"` - Handle string `json:"handle"` - Name string `json:"name"` - Description *string `json:"description,omitempty"` - Url *string `json:"url,omitempty"` - WorkflowStatus string `json:"workflowStatus"` - AuthType string `json:"authType"` - AuthConfig map[string]interface{} `json:"authConfig,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` + Id string `json:"id"` + Handle string `json:"handle"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Url string `json:"url"` + AuthType string `json:"authType"` + AuthConfig map[string]interface{} `json:"authConfig,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` } type apiPortalListResp struct { @@ -156,6 +155,7 @@ func TestAPIPortalHandler_Create_HappyPath(t *testing.T) { body := mustJSON(t, map[string]any{ "name": "Acme Portal", "handle": "acme", + "url": "https://acme.example.com", "authType": "local", "metadata": map[string]any{"stsIssuer": "https://sts.example.com"}, }) @@ -175,7 +175,7 @@ func TestAPIPortalHandler_Create_HappyPath(t *testing.T) { t.Fatalf("decode: %v", err) } if got.Id != "acme" || got.Handle != "acme" || got.Name != "Acme Portal" || - got.AuthType != "local" || got.WorkflowStatus != "pending" { + got.AuthType != "local" || got.Url != "https://acme.example.com" { t.Errorf("response fields wrong: %+v", got) } if got.Metadata["stsIssuer"] != "https://sts.example.com" { @@ -234,6 +234,7 @@ func TestAPIPortalHandler_Create_MissingName(t *testing.T) { body := mustJSON(t, map[string]any{ "handle": "acme", + "url": "https://acme.example.com", "authType": "local", }) req := apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body) @@ -245,47 +246,20 @@ func TestAPIPortalHandler_Create_MissingName(t *testing.T) { } } -func TestAPIPortalHandler_Create_WithActiveStatus(t *testing.T) { +func TestAPIPortalHandler_Create_MissingURL(t *testing.T) { r, _, cleanup := setupAPIPortalHandlerEnv(t) t.Cleanup(cleanup) body := mustJSON(t, map[string]any{ - "name": "Acme Portal", - "handle": "acme-active", - "authType": "local", - "url": "https://acme.example.com", - "workflowStatus": "active", - }) - req := apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body) - rec := httptest.NewRecorder() - r.ServeHTTP(rec, req) - if rec.Code != http.StatusCreated { - t.Fatalf("Create: want 201, got %d: %s", rec.Code, rec.Body.String()) - } - var got apiPortalResp - if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v", err) - } - if got.WorkflowStatus != "active" { - t.Errorf("want active, got %q", got.WorkflowStatus) - } -} - -func TestAPIPortalHandler_Create_ActiveWithoutURL(t *testing.T) { - r, _, cleanup := setupAPIPortalHandlerEnv(t) - t.Cleanup(cleanup) - - body := mustJSON(t, map[string]any{ - "name": "Acme Portal", - "handle": "acme-bad", - "authType": "local", - "workflowStatus": "active", + "name": "Acme Portal", + "handle": "acme-nourl", + "authType": "local", }) req := apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { - t.Fatalf("Create: want 400 for active without url, got %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("Create: want 400 for missing url, got %d: %s", rec.Code, rec.Body.String()) } } @@ -293,7 +267,12 @@ func TestAPIPortalHandler_Create_HandleConflict(t *testing.T) { r, _, cleanup := setupAPIPortalHandlerEnv(t) t.Cleanup(cleanup) - body := mustJSON(t, map[string]any{"name": "a", "handle": "dup", "authType": "local"}) + body := mustJSON(t, map[string]any{ + "name": "a", + "handle": "dup", + "url": "https://a.example.com", + "authType": "local", + }) req := apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -321,7 +300,12 @@ func TestAPIPortalHandler_Create_MissingOrg(t *testing.T) { r, _, cleanup := setupAPIPortalHandlerEnv(t) t.Cleanup(cleanup) - body := mustJSON(t, map[string]any{"name": "a", "handle": "acme", "authType": "local"}) + body := mustJSON(t, map[string]any{ + "name": "a", + "handle": "acme", + "url": "https://acme.example.com", + "authType": "local", + }) // Deliberately DO NOT set X-Test-Org; expect 401 from the handler's org guard. req := httptest.NewRequest(http.MethodPost, apiPortalTestBase, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") @@ -340,7 +324,12 @@ func TestAPIPortalHandler_Get_HappyPath(t *testing.T) { t.Cleanup(cleanup) // Seed via POST. - body := mustJSON(t, map[string]any{"name": "Acme", "handle": "acme", "authType": "local"}) + body := mustJSON(t, map[string]any{ + "name": "Acme", + "handle": "acme", + "url": "https://acme.example.com", + "authType": "local", + }) rec := httptest.NewRecorder() r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body)) if rec.Code != http.StatusCreated { @@ -387,7 +376,12 @@ func TestAPIPortalHandler_List_HappyPath(t *testing.T) { // Seed 3 portals. for _, h := range []string{"one", "two", "three"} { - body := mustJSON(t, map[string]any{"name": "P " + h, "handle": h, "authType": "local"}) + body := mustJSON(t, map[string]any{ + "name": "P " + h, + "handle": h, + "url": "https://" + h + ".example.com", + "authType": "local", + }) rec := httptest.NewRecorder() r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body)) if rec.Code != http.StatusCreated { @@ -412,39 +406,6 @@ func TestAPIPortalHandler_List_HappyPath(t *testing.T) { } } -func TestAPIPortalHandler_List_WorkflowStatusFilter(t *testing.T) { - r, db, cleanup := setupAPIPortalHandlerEnv(t) - t.Cleanup(cleanup) - - // Seed 3 portals (handle min length is 3). WorkflowStatus can't be set on - // Create body (it defaults to "pending" server-side), so bump one row via - // SQL directly to exercise the status filter. - for _, h := range []string{"aaa", "bbb", "ccc"} { - body := mustJSON(t, map[string]any{"name": "P " + h, "handle": h, "authType": "local"}) - rec := httptest.NewRecorder() - r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body)) - if rec.Code != http.StatusCreated { - t.Fatalf("seed %s: %d %s", h, rec.Code, rec.Body.String()) - } - } - if _, err := db.Exec(`UPDATE api_portals SET workflow_status = 'active' WHERE handle = 'ccc'`); err != nil { - t.Fatalf("bump status: %v", err) - } - - rec := httptest.NewRecorder() - r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodGet, apiPortalTestBase+"?workflowStatus=active", nil)) - if rec.Code != http.StatusOK { - t.Fatalf("List: want 200, got %d: %s", rec.Code, rec.Body.String()) - } - var got apiPortalListResp - if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v", err) - } - if got.Count != 1 || got.List[0].Handle != "ccc" { - t.Errorf("filter miss: %+v", got) - } -} - // --- UPDATE --- func TestAPIPortalHandler_Update_HappyPath(t *testing.T) { @@ -452,7 +413,12 @@ func TestAPIPortalHandler_Update_HappyPath(t *testing.T) { t.Cleanup(cleanup) // Seed. - body := mustJSON(t, map[string]any{"name": "old", "handle": "acme", "authType": "local"}) + body := mustJSON(t, map[string]any{ + "name": "old", + "handle": "acme", + "url": "https://acme.example.com", + "authType": "local", + }) rec := httptest.NewRecorder() r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body)) if rec.Code != http.StatusCreated { @@ -504,7 +470,12 @@ func TestAPIPortalHandler_Delete_HappyPath(t *testing.T) { r, _, cleanup := setupAPIPortalHandlerEnv(t) t.Cleanup(cleanup) - body := mustJSON(t, map[string]any{"name": "x", "handle": "gone", "authType": "local"}) + body := mustJSON(t, map[string]any{ + "name": "x", + "handle": "gone", + "url": "https://gone.example.com", + "authType": "local", + }) rec := httptest.NewRecorder() r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body)) if rec.Code != http.StatusCreated { diff --git a/platform-api/internal/repository/api_portal.go b/platform-api/internal/repository/api_portal.go index 60cc9867e3..c100bc0766 100644 --- a/platform-api/internal/repository/api_portal.go +++ b/platform-api/internal/repository/api_portal.go @@ -168,16 +168,11 @@ func (r *APIPortalRepo) GetByHandleAndOrgID(handle, orgUUID string) (*model.APIP return portal, nil } -// ListPaginated returns a page of API Portals scoped to the organization, -// optionally filtered by workflow_status. -func (r *APIPortalRepo) ListPaginated(orgUUID string, workflowStatus *string, opts ListOptions) ([]*model.APIPortal, error) { +// ListPaginated returns a page of API Portals scoped to the organization. +func (r *APIPortalRepo) ListPaginated(orgUUID string, opts ListOptions) ([]*model.APIPortal, error) { var args []interface{} conditions := []string{`organization_uuid = ?`} args = append(args, orgUUID) - if workflowStatus != nil { - conditions = append(conditions, `workflow_status = ?`) - args = append(args, *workflowStatus) - } if searchClause, searchArgs := handleSearchClause(opts.Search); searchClause != "" { conditions = append(conditions, strings.TrimPrefix(searchClause, " AND ")) args = append(args, searchArgs...) @@ -210,15 +205,11 @@ func (r *APIPortalRepo) ListPaginated(orgUUID string, workflowStatus *string, op return portals, rows.Err() } -// Count returns the total number of API Portals matching the filter, independent of pagination. -func (r *APIPortalRepo) Count(orgUUID string, workflowStatus *string, search string) (int, error) { +// Count returns the total number of API Portals matching the org (+ optional search), independent of pagination. +func (r *APIPortalRepo) Count(orgUUID string, search string) (int, error) { var args []interface{} conditions := []string{`organization_uuid = ?`} args = append(args, orgUUID) - if workflowStatus != nil { - conditions = append(conditions, `workflow_status = ?`) - args = append(args, *workflowStatus) - } if searchClause, searchArgs := handleSearchClause(search); searchClause != "" { conditions = append(conditions, strings.TrimPrefix(searchClause, " AND ")) args = append(args, searchArgs...) diff --git a/platform-api/internal/repository/api_portal_test.go b/platform-api/internal/repository/api_portal_test.go index 0dcd39d651..9adc206077 100644 --- a/platform-api/internal/repository/api_portal_test.go +++ b/platform-api/internal/repository/api_portal_test.go @@ -275,7 +275,7 @@ func TestAPIPortalRepo_ListPaginated(t *testing.T) { } // Page 1: limit 2 → newest first ("ee", "dd"). - page1, err := repo.ListPaginated(orgUUID, nil, ListOptions{Limit: 2, Offset: 0}) + page1, err := repo.ListPaginated(orgUUID, ListOptions{Limit: 2, Offset: 0}) if err != nil { t.Fatalf("ListPaginated page 1: %v", err) } @@ -287,7 +287,7 @@ func TestAPIPortalRepo_ListPaginated(t *testing.T) { } // Page 2: offset 2, limit 2 → "cc", "bb". - page2, err := repo.ListPaginated(orgUUID, nil, ListOptions{Limit: 2, Offset: 2}) + page2, err := repo.ListPaginated(orgUUID, ListOptions{Limit: 2, Offset: 2}) if err != nil { t.Fatalf("ListPaginated page 2: %v", err) } @@ -296,7 +296,7 @@ func TestAPIPortalRepo_ListPaginated(t *testing.T) { } // Count without filter. - total, err := repo.Count(orgUUID, nil, "") + total, err := repo.Count(orgUUID, "") if err != nil { t.Fatalf("Count: %v", err) } @@ -305,50 +305,6 @@ func TestAPIPortalRepo_ListPaginated(t *testing.T) { } } -func TestAPIPortalRepo_ListPaginated_WorkflowStatusFilter(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - const orgUUID = "org-portal-status" - createTestAPIPortalOrg(t, db, orgUUID) - - repo := NewAPIPortalRepo(db) - // 2 pending, 1 active. - p1 := newTestAPIPortal("p1", orgUUID, "p1") - p1.WorkflowStatus = constants.APIPortalWorkflowStatusPending - if err := repo.Create(p1); err != nil { - t.Fatalf("Create p1: %v", err) - } - p2 := newTestAPIPortal("p2", orgUUID, "p2") - p2.WorkflowStatus = constants.APIPortalWorkflowStatusPending - if err := repo.Create(p2); err != nil { - t.Fatalf("Create p2: %v", err) - } - p3 := newTestAPIPortal("p3", orgUUID, "p3") - p3.WorkflowStatus = constants.APIPortalWorkflowStatusActive - if err := repo.Create(p3); err != nil { - t.Fatalf("Create p3: %v", err) - } - - active := constants.APIPortalWorkflowStatusActive - got, err := repo.ListPaginated(orgUUID, &active, ListOptions{Limit: 10, Offset: 0}) - if err != nil { - t.Fatalf("ListPaginated: %v", err) - } - if len(got) != 1 || got[0].Handle != "p3" { - t.Errorf("want 1 active portal (p3); got %+v", got) - } - - // Count with same filter must also reflect it (pagination-total consistency). - total, err := repo.Count(orgUUID, &active, "") - if err != nil { - t.Fatalf("Count: %v", err) - } - if total != 1 { - t.Errorf("filtered count: want 1, got %d", total) - } -} - func TestAPIPortalRepo_ListPaginated_Search(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() @@ -362,7 +318,7 @@ func TestAPIPortalRepo_ListPaginated_Search(t *testing.T) { t.Fatalf("Create %s: %v", h, err) } } - got, err := repo.ListPaginated(orgUUID, nil, ListOptions{Limit: 10, Offset: 0, Search: "acme"}) + got, err := repo.ListPaginated(orgUUID, ListOptions{Limit: 10, Offset: 0, Search: "acme"}) if err != nil { t.Fatalf("ListPaginated: %v", err) } diff --git a/platform-api/internal/repository/interfaces.go b/platform-api/internal/repository/interfaces.go index 404babab5b..990dcf31d5 100644 --- a/platform-api/internal/repository/interfaces.go +++ b/platform-api/internal/repository/interfaces.go @@ -301,13 +301,10 @@ type APIPortalRepository interface { Create(portal *model.APIPortal) error GetByUUID(portalID, orgUUID string) (*model.APIPortal, error) GetByHandleAndOrgID(handle, orgUUID string) (*model.APIPortal, error) - // ListPaginated returns a page of API Portals scoped to the organization, - // optionally filtered by workflow_status. When workflowStatus is nil, all - // statuses are included. - ListPaginated(orgUUID string, workflowStatus *string, opts ListOptions) ([]*model.APIPortal, error) - // Count returns the total number of matching API Portals independent of - // pagination. workflowStatus follows the same rules as ListPaginated. - Count(orgUUID string, workflowStatus *string, search string) (int, error) + // ListPaginated returns a page of API Portals scoped to the organization. + ListPaginated(orgUUID string, opts ListOptions) ([]*model.APIPortal, error) + // Count returns the total number of matching API Portals independent of pagination. + Count(orgUUID string, search string) (int, error) Update(portal *model.APIPortal) error Delete(portalID, orgUUID string) error Exists(handle, orgUUID string) (bool, error) diff --git a/platform-api/internal/service/api_portal.go b/platform-api/internal/service/api_portal.go index 42372732e3..0dec26c3b1 100644 --- a/platform-api/internal/service/api_portal.go +++ b/platform-api/internal/service/api_portal.go @@ -220,14 +220,13 @@ func copyStringMap(m map[string]interface{}) map[string]interface{} { // Fields mirror the OpenAPI CreateApiPortalRequest but stay independent of the // generated types. type CreateAPIPortalRequest struct { - Handle string - Name string - Description string - URL string - WorkflowStatus string // optional; defaults to "pending" - AuthType string - AuthConfig map[string]interface{} - Metadata map[string]interface{} + Handle string + Name string + Description string + URL string + AuthType string + AuthConfig map[string]interface{} + Metadata map[string]interface{} } // UpdateAPIPortalRequest carries mutable fields for a partial update. Pointer @@ -244,19 +243,17 @@ type CreateAPIPortalRequest struct { // replaces the stored metadata. Callers that want a partial-update on metadata // should GET, modify, PUT the whole thing. type UpdateAPIPortalRequest struct { - Name *string - Description *string - URL *string - WorkflowStatus *string - AuthType *string - AuthConfig map[string]interface{} // when nil, existing preserved; when non-nil, merged in - Metadata map[string]interface{} // when nil, existing preserved; when non-nil, replaces + Name *string + Description *string + URL *string + AuthType *string + AuthConfig map[string]interface{} // when nil, existing preserved; when non-nil, merged in + Metadata map[string]interface{} // when nil, existing preserved; when non-nil, replaces } -// APIPortalListOptions bundles the pagination + filter inputs for List. +// APIPortalListOptions bundles the pagination inputs for List. type APIPortalListOptions struct { repository.ListOptions - WorkflowStatus *string } // APIPortalListResponse is the service-layer list result. The handler wraps @@ -292,20 +289,12 @@ func (s *APIPortalService) CreateAPIPortal(req *CreateAPIPortalRequest, orgID, c return nil, apperror.ValidationFailed.New( fmt.Sprintf("The authType %q is not supported.", authType)) } - workflowStatus := strings.TrimSpace(req.WorkflowStatus) - if workflowStatus == "" { - workflowStatus = constants.APIPortalWorkflowStatusPending - } else if !constants.ValidAPIPortalCreateWorkflowStatuses[workflowStatus] { - return nil, apperror.ValidationFailed.New( - fmt.Sprintf("The workflowStatus %q is not supported on create.", workflowStatus)) - } portalURL, err := validateAPIPortalURL(req.URL) if err != nil { return nil, err } - if workflowStatus == constants.APIPortalWorkflowStatusActive && portalURL == "" { - return nil, apperror.ValidationFailed.New( - "The workflowStatus cannot be active when url is empty.") + if portalURL == "" { + return nil, apperror.ValidationFailed.New("The url field is required.") } // Copy the incoming authConfig so we don't mutate the caller's map when we // encrypt secret fields in place. @@ -341,7 +330,7 @@ func (s *APIPortalService) CreateAPIPortal(req *CreateAPIPortalRequest, orgID, c Name: name, Description: strings.TrimSpace(req.Description), URL: portalURL, - WorkflowStatus: workflowStatus, + WorkflowStatus: constants.APIPortalWorkflowStatusActive, AuthType: authType, AuthConfig: authConfig, Metadata: req.Metadata, @@ -391,23 +380,11 @@ func (s *APIPortalService) ListAPIPortals(orgID string, opts APIPortalListOption if opts.Offset < 0 { opts.Offset = 0 } - if opts.WorkflowStatus != nil { - trimmed := strings.TrimSpace(*opts.WorkflowStatus) - if trimmed == "" { - opts.WorkflowStatus = nil - } else if !constants.ValidAPIPortalWorkflowStatuses[trimmed] { - return nil, apperror.ValidationFailed.New( - fmt.Sprintf("The workflowStatus %q is not supported.", trimmed)) - } else { - opts.WorkflowStatus = &trimmed - } - } - - total, err := s.portalRepo.Count(orgID, opts.WorkflowStatus, opts.Search) + total, err := s.portalRepo.Count(orgID, opts.Search) if err != nil { return nil, err } - page, err := s.portalRepo.ListPaginated(orgID, opts.WorkflowStatus, opts.ListOptions) + page, err := s.portalRepo.ListPaginated(orgID, opts.ListOptions) if err != nil { return nil, err } @@ -447,15 +424,10 @@ func (s *APIPortalService) UpdateAPIPortal(handle string, req *UpdateAPIPortalRe if err != nil { return nil, err } - portal.URL = portalURL - } - if req.WorkflowStatus != nil { - ws := strings.TrimSpace(*req.WorkflowStatus) - if !constants.ValidAPIPortalWorkflowStatuses[ws] { - return nil, apperror.ValidationFailed.New( - fmt.Sprintf("The workflowStatus %q is not supported.", ws)) + if portalURL == "" { + return nil, apperror.ValidationFailed.New("The url field cannot be empty.") } - portal.WorkflowStatus = ws + portal.URL = portalURL } if req.AuthType != nil { at := strings.TrimSpace(*req.AuthType) @@ -492,14 +464,6 @@ func (s *APIPortalService) UpdateAPIPortal(handle string, req *UpdateAPIPortalRe if err := validateAPIPortalAuthConfig(portal.AuthType, portal.AuthConfig); err != nil { return nil, err } - // After applying all whitelisted mutations, enforce the cross-field rule: - // a portal cannot be in the active state without a URL. This catches both - // "set workflowStatus=active while url is empty" and "clear url while - // status is currently active". - if portal.WorkflowStatus == constants.APIPortalWorkflowStatusActive && portal.URL == "" { - return nil, apperror.ValidationFailed.New( - "The workflowStatus cannot be active when url is empty.") - } portal.UpdatedBy = strings.TrimSpace(updatedBy) if err := s.portalRepo.Update(portal); err != nil { diff --git a/platform-api/internal/service/api_portal_test.go b/platform-api/internal/service/api_portal_test.go index e7b1ada59b..8b7fb71753 100644 --- a/platform-api/internal/service/api_portal_test.go +++ b/platform-api/internal/service/api_portal_test.go @@ -90,11 +90,11 @@ func (m *mockAPIPortalRepository) GetByHandleAndOrgID(handle, orgUUID string) (* return m.getResult, m.getErr } -func (m *mockAPIPortalRepository) ListPaginated(orgUUID string, workflowStatus *string, opts repository.ListOptions) ([]*model.APIPortal, error) { +func (m *mockAPIPortalRepository) ListPaginated(orgUUID string, opts repository.ListOptions) ([]*model.APIPortal, error) { return m.listResult, m.listErr } -func (m *mockAPIPortalRepository) Count(orgUUID string, workflowStatus *string, search string) (int, error) { +func (m *mockAPIPortalRepository) Count(orgUUID string, search string) (int, error) { return m.countResult, m.countErr } @@ -172,8 +172,10 @@ func TestAPIPortalService_CreateAPIPortal_HappyPath(t *testing.T) { if got == nil || got.Handle != "acme" || got.Name != "Acme Portal" { t.Errorf("returned portal wrong shape: %+v", got) } - if got.WorkflowStatus != constants.APIPortalWorkflowStatusPending { - t.Errorf("default workflowStatus: want pending, got %q", got.WorkflowStatus) + // OSS registers a portal that's already running; workflowStatus is always + // active from create, and is not exposed on the wire. + if got.WorkflowStatus != constants.APIPortalWorkflowStatusActive { + t.Errorf("default workflowStatus: want active, got %q", got.WorkflowStatus) } if got.ID == "" { t.Error("expected generated UUID, got empty") @@ -230,23 +232,11 @@ func TestAPIPortalService_CreateAPIPortal_InvalidAuthType(t *testing.T) { } } -func TestAPIPortalService_CreateAPIPortal_InvalidWorkflowStatus(t *testing.T) { - svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}) - _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ - Handle: "acme", - Name: "Acme", - AuthType: constants.APIPortalAuthTypeLocal, - WorkflowStatus: "not-a-real-status", - }, "org-1", "user-1") - if err == nil { - t.Fatal("expected error for invalid workflowStatus") - } -} - func TestAPIPortalService_CreateAPIPortal_OrgNotFound(t *testing.T) { svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: nil}, &mockAPIPortalAuditRepository{}) _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ Handle: "acme", Name: "Acme", AuthType: constants.APIPortalAuthTypeLocal, + URL: "https://acme.example.com", }, "org-missing", "user-1") if err == nil || !apperror.OrganizationNotFound.Is(err) { t.Fatalf("want OrganizationNotFound, got %v", err) @@ -261,6 +251,7 @@ func TestAPIPortalService_CreateAPIPortal_HandleAlreadyExists(t *testing.T) { ) _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ Handle: "acme", Name: "Acme", AuthType: constants.APIPortalAuthTypeLocal, + URL: "https://acme.example.com", }, "org-1", "user-1") if err == nil || !apperror.APIPortalExists.Is(err) { t.Fatalf("want APIPortalExists, got %v", err) @@ -270,13 +261,14 @@ func TestAPIPortalService_CreateAPIPortal_HandleAlreadyExists(t *testing.T) { func TestAPIPortalService_CreateAPIPortal_RaceOnUniqueConstraint(t *testing.T) { // Exists() returns false (no row yet), then Create() races against another // insert and hits the UNIQUE constraint. Service must translate to Conflict. - svc := newTestAPIPortalService(t, + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{existsResult: false, createReturnUnique: true}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}, ) _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ Handle: "acme", Name: "Acme", AuthType: constants.APIPortalAuthTypeLocal, + URL: "https://acme.example.com", }, "org-1", "user-1") if err == nil || !apperror.APIPortalExists.Is(err) { t.Fatalf("want APIPortalExists on race, got %v", err) @@ -335,9 +327,9 @@ func TestAPIPortalService_CreateAPIPortal_ValidHTTPSAccepted(t *testing.T) { } } -func TestAPIPortalService_CreateAPIPortal_EmptyURLAllowed(t *testing.T) { - // Cloud provisioning starts with URL null; empty must pass validation. - svc := newTestAPIPortalService(t, +func TestAPIPortalService_CreateAPIPortal_EmptyURLRejected(t *testing.T) { + // OSS requires the operator to supply a reachable URL. Empty is rejected. + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}, @@ -348,126 +340,8 @@ func TestAPIPortalService_CreateAPIPortal_EmptyURLAllowed(t *testing.T) { AuthType: constants.APIPortalAuthTypeLocal, URL: "", }, "org-1", "user-1") - if err != nil { - t.Fatalf("empty URL rejected: %v", err) - } -} - -func TestAPIPortalService_CreateAPIPortal_ActiveWithURL(t *testing.T) { - svc := newTestAPIPortalService(t, - &mockAPIPortalRepository{}, - &mockAPIPortalOrgRepository{result: &model.Organization{}}, - &mockAPIPortalAuditRepository{}, - ) - got, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ - Handle: "acme", Name: "Acme", AuthType: constants.APIPortalAuthTypeLocal, - URL: "https://acme.example.com", - WorkflowStatus: constants.APIPortalWorkflowStatusActive, - }, "org-1", "user-1") - if err != nil { - t.Fatalf("CreateAPIPortal: %v", err) - } - if got.WorkflowStatus != constants.APIPortalWorkflowStatusActive { - t.Errorf("want active, got %q", got.WorkflowStatus) - } -} - -func TestAPIPortalService_CreateAPIPortal_ActiveWithoutURLRejected(t *testing.T) { - svc := newTestAPIPortalService(t, - &mockAPIPortalRepository{}, - &mockAPIPortalOrgRepository{result: &model.Organization{}}, - &mockAPIPortalAuditRepository{}, - ) - _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ - Handle: "acme", Name: "Acme", AuthType: constants.APIPortalAuthTypeLocal, - URL: "", - WorkflowStatus: constants.APIPortalWorkflowStatusActive, - }, "org-1", "user-1") - if err == nil || !apperror.ValidationFailed.Is(err) { - t.Fatalf("want ValidationFailed for active + empty url, got %v", err) - } -} - -func TestAPIPortalService_CreateAPIPortal_FailedStatusRejected(t *testing.T) { - svc := newTestAPIPortalService(t, - &mockAPIPortalRepository{}, - &mockAPIPortalOrgRepository{result: &model.Organization{}}, - &mockAPIPortalAuditRepository{}, - ) - _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ - Handle: "acme", Name: "Acme", AuthType: constants.APIPortalAuthTypeLocal, - URL: "https://acme.example.com", - WorkflowStatus: constants.APIPortalWorkflowStatusFailed, - }, "org-1", "user-1") - if err == nil || !apperror.ValidationFailed.Is(err) { - t.Fatalf("want ValidationFailed for failed status on create, got %v", err) - } -} - -func TestAPIPortalService_UpdateAPIPortal_ActivateWithoutURLRejected(t *testing.T) { - // Existing row has no URL; caller tries to flip status to active. - existing := &model.APIPortal{ - ID: "p1", Handle: "acme", OrganizationID: "org-1", - Name: "Acme", URL: "", - WorkflowStatus: constants.APIPortalWorkflowStatusPending, - AuthType: constants.APIPortalAuthTypeLocal, - } - svc := newTestAPIPortalService(t, - &mockAPIPortalRepository{getResult: existing}, - &mockAPIPortalOrgRepository{}, - &mockAPIPortalAuditRepository{}, - ) - _, err := svc.UpdateAPIPortal("acme", &UpdateAPIPortalRequest{ - WorkflowStatus: apiPortalStrPtr(constants.APIPortalWorkflowStatusActive), - }, "org-1", "editor") - if err == nil || !apperror.ValidationFailed.Is(err) { - t.Fatalf("want ValidationFailed activating without URL, got %v", err) - } -} - -func TestAPIPortalService_UpdateAPIPortal_ClearURLWhileActiveRejected(t *testing.T) { - // Existing row is active with a URL; caller tries to clear the URL. - existing := &model.APIPortal{ - ID: "p1", Handle: "acme", OrganizationID: "org-1", - Name: "Acme", URL: "https://acme.example.com", - WorkflowStatus: constants.APIPortalWorkflowStatusActive, - AuthType: constants.APIPortalAuthTypeLocal, - } - svc := newTestAPIPortalService(t, - &mockAPIPortalRepository{getResult: existing}, - &mockAPIPortalOrgRepository{}, - &mockAPIPortalAuditRepository{}, - ) - _, err := svc.UpdateAPIPortal("acme", &UpdateAPIPortalRequest{ - URL: apiPortalStrPtr(""), - }, "org-1", "editor") if err == nil || !apperror.ValidationFailed.Is(err) { - t.Fatalf("want ValidationFailed clearing URL while active, got %v", err) - } -} - -func TestAPIPortalService_UpdateAPIPortal_ActivateWithNewURL(t *testing.T) { - // Provisioner-callback scenario: single PUT sets both URL and status. - existing := &model.APIPortal{ - ID: "p1", Handle: "acme", OrganizationID: "org-1", - Name: "Acme", URL: "", - WorkflowStatus: constants.APIPortalWorkflowStatusPending, - AuthType: constants.APIPortalAuthTypeLocal, - } - svc := newTestAPIPortalService(t, - &mockAPIPortalRepository{getResult: existing}, - &mockAPIPortalOrgRepository{}, - &mockAPIPortalAuditRepository{}, - ) - got, err := svc.UpdateAPIPortal("acme", &UpdateAPIPortalRequest{ - URL: apiPortalStrPtr("https://acme.example.com"), - WorkflowStatus: apiPortalStrPtr(constants.APIPortalWorkflowStatusActive), - }, "org-1", "editor") - if err != nil { - t.Fatalf("UpdateAPIPortal: %v", err) - } - if got.URL != "https://acme.example.com" || got.WorkflowStatus != constants.APIPortalWorkflowStatusActive { - t.Errorf("provisioner-callback path did not apply both fields: %+v", got) + t.Fatalf("want ValidationFailed for empty URL, got %v", err) } } @@ -597,14 +471,6 @@ func TestAPIPortalService_ListAPIPortals_LimitClamping(t *testing.T) { } } -func TestAPIPortalService_ListAPIPortals_InvalidWorkflowStatus(t *testing.T) { - svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}) - _, err := svc.ListAPIPortals("org-1", APIPortalListOptions{WorkflowStatus: apiPortalStrPtr("bogus")}) - if err == nil || !apperror.ValidationFailed.Is(err) { - t.Fatalf("want ValidationFailed, got %v", err) - } -} - // --- Update tests --- func TestAPIPortalService_UpdateAPIPortal_HappyPath(t *testing.T) { @@ -619,9 +485,8 @@ func TestAPIPortalService_UpdateAPIPortal_HappyPath(t *testing.T) { svc := newTestAPIPortalService(t, portalRepo, &mockAPIPortalOrgRepository{}, auditRepo) req := &UpdateAPIPortalRequest{ - Name: apiPortalStrPtr("Renamed"), - WorkflowStatus: apiPortalStrPtr(constants.APIPortalWorkflowStatusActive), - AuthType: apiPortalStrPtr(constants.APIPortalAuthTypeOAuth2), + Name: apiPortalStrPtr("Renamed"), + AuthType: apiPortalStrPtr(constants.APIPortalAuthTypeOAuth2), AuthConfig: map[string]interface{}{ "stsTokenUrl": "https://sts.example.com/token", "clientId": "abc", @@ -632,8 +497,7 @@ func TestAPIPortalService_UpdateAPIPortal_HappyPath(t *testing.T) { if err != nil { t.Fatalf("UpdateAPIPortal: %v", err) } - if got.Name != "Renamed" || got.WorkflowStatus != constants.APIPortalWorkflowStatusActive || - got.AuthType != constants.APIPortalAuthTypeOAuth2 { + if got.Name != "Renamed" || got.AuthType != constants.APIPortalAuthTypeOAuth2 { t.Errorf("mutable fields not applied: %+v", got) } if got.Handle != "acme" || got.ID != "p1" { @@ -667,7 +531,6 @@ func TestAPIPortalService_UpdateAPIPortal_PartialUpdate(t *testing.T) { t.Errorf("Description not updated: %q", got.Description) } if got.Name != "keep" || got.URL != "https://keep.example.com" || - got.WorkflowStatus != constants.APIPortalWorkflowStatusActive || got.AuthType != constants.APIPortalAuthTypeLocal { t.Errorf("unset fields were mutated: %+v", got) } @@ -690,15 +553,6 @@ func TestAPIPortalService_UpdateAPIPortal_EmptyName(t *testing.T) { } } -func TestAPIPortalService_UpdateAPIPortal_InvalidWorkflowStatus(t *testing.T) { - existing := &model.APIPortal{ID: "p1", Handle: "acme", OrganizationID: "org-1"} - svc := newTestAPIPortalService(t, &mockAPIPortalRepository{getResult: existing}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) - _, err := svc.UpdateAPIPortal("acme", &UpdateAPIPortalRequest{WorkflowStatus: apiPortalStrPtr("bogus")}, "org-1", "editor") - if err == nil || !apperror.ValidationFailed.Is(err) { - t.Fatalf("want ValidationFailed, got %v", err) - } -} - // --- Delete tests --- func TestAPIPortalService_DeleteAPIPortal_HappyPath(t *testing.T) { diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index 8334e0fd37..7f0d2efeed 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -4514,7 +4514,6 @@ paths: - $ref: '#/components/parameters/sortBy-Q' - $ref: '#/components/parameters/sortOrder-Q' - $ref: '#/components/parameters/query-Q' - - $ref: '#/components/parameters/apiPortalWorkflowStatus-Q' responses: '200': description: API Portals retrieved successfully @@ -8869,7 +8868,7 @@ components: - id - name - handle - - workflowStatus + - url - authType - createdAt - updatedAt @@ -8903,19 +8902,8 @@ components: url: type: string format: uri - nullable: true - description: >- - Public URL of the API Portal. May be null while the portal is being - provisioned; populated once the instance is reachable. + description: Public URL of the API Portal. Operator-supplied. example: "https://acme-portal.example.com" - workflowStatus: - type: string - enum: [pending, active, failed] - description: >- - Lifecycle state. `pending` — portal is being provisioned or activated. - `active` — portal is reachable and functional. `failed` — provisioning - or a subsequent health check failed. - example: "active" authType: type: string enum: [local, oauth2] @@ -8946,7 +8934,7 @@ components: - id - name - handle - - workflowStatus + - url - authType - createdAt properties: @@ -8967,10 +8955,6 @@ components: url: type: string format: uri - nullable: true - workflowStatus: - type: string - enum: [pending, active, failed] authType: type: string enum: [local, oauth2] @@ -8984,6 +8968,7 @@ components: required: - name - handle + - url - authType properties: name: @@ -9003,22 +8988,10 @@ components: url: type: string format: uri - nullable: true - description: >- - Public URL of an existing API Portal to register. Omit to have a new - portal provisioned; the URL will be populated once the instance is - reachable. + description: Public URL of the API Portal to register. Operator-supplied. authType: type: string enum: [local, oauth2] - workflowStatus: - type: string - enum: [pending, active] - description: >- - Optional. Defaults to `pending` when omitted. Setting `active` - requires a non-empty `url`; the request is rejected otherwise. - `failed` cannot be set on create — a portal is never created in a - failed state. authConfig: $ref: '#/components/schemas/ApiPortalAuthConfig' metadata: @@ -9040,10 +9013,6 @@ components: url: type: string format: uri - nullable: true - workflowStatus: - type: string - enum: [pending, active, failed] authType: type: string enum: [local, oauth2] @@ -9425,16 +9394,6 @@ components: maxLength: 40 example: "acme-portal" - apiPortalWorkflowStatus-Q: - name: workflowStatus - in: query - required: false - description: Filter API Portals by lifecycle state. - schema: - type: string - enum: [pending, active, failed] - example: "active" - tags: - name: Health description: Health check endpoints From f87bfc438cca1bdfb2d9447ca0a638760bb14b0a Mon Sep 17 00:00:00 2001 From: dushaniw Date: Tue, 25 Aug 2026 14:49:24 +0530 Subject: [PATCH 16/25] =?UTF-8?q?Rename=20workflow=5Fstatus=20=E2=86=92=20?= =?UTF-8?q?status=20on=20the=20api=5Fportals=20column=20and=20Go=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portal creation has no workflow of its own, so calling the lifecycle column "workflow_status" was misleading — plain "status" is what it actually holds. Rename everywhere the name reaches: - DB schema: workflow_status → status on the api_portals table in schema.postgres.sql, schema.sqlite.sql, schema.sqlserver.sql. - Model: APIPortal.WorkflowStatus → APIPortal.Status, with db tag "status" and json tag "status" (the json tag isn't marshalled directly by handlers, but keep it consistent). - Constants: APIPortalWorkflowStatus{Pending,Active,Failed} → APIPortalStatus{Pending,Active,Failed}. Doc comment updated to drop "workflow". - Repository: SELECT column list, INSERT column list, scan order, and the field's use in Create/Update all reference the new name. - Service: constant refs updated; the field is now .Status on the model. - Tests: fixture and assertion refs follow the renames; test comments no longer mention "workflow status". Wire surface is unchanged (the field was already removed from the OpenAPI in the previous commit). This is a pure internal rename. Co-Authored-By: Claude Opus 4.7 (1M context) --- platform-api/internal/constants/constants.go | 10 +++++----- .../internal/database/schema.postgres.sql | 2 +- .../internal/database/schema.sqlite.sql | 2 +- .../internal/database/schema.sqlserver.sql | 2 +- platform-api/internal/model/api_portal.go | 8 ++++---- .../internal/repository/api_portal.go | 12 +++++------ .../internal/repository/api_portal_test.go | 6 +++--- platform-api/internal/service/api_portal.go | 2 +- .../internal/service/api_portal_test.go | 20 +++++++++---------- 9 files changed, 32 insertions(+), 32 deletions(-) diff --git a/platform-api/internal/constants/constants.go b/platform-api/internal/constants/constants.go index 18f042643a..85bc586339 100644 --- a/platform-api/internal/constants/constants.go +++ b/platform-api/internal/constants/constants.go @@ -207,14 +207,14 @@ var ValidGatewayTokenStatuses = map[string]bool{ GatewayTokenStatusRevoked: true, } -// API Portal workflow status constants. The column exists on api_portals for +// API Portal status constants. The column exists on api_portals for // future extensibility but is not surfaced on the wire in the OSS offering: // OSS registers a portal that's already running, so every OSS row is created -// as APIPortalWorkflowStatusActive and never mutated by clients. +// as APIPortalStatusActive and never mutated by clients. const ( - APIPortalWorkflowStatusPending = "pending" - APIPortalWorkflowStatusActive = "active" - APIPortalWorkflowStatusFailed = "failed" + APIPortalStatusPending = "pending" + APIPortalStatusActive = "active" + APIPortalStatusFailed = "failed" ) // API Portal authConfig field-name constants used by Create/Update validation diff --git a/platform-api/internal/database/schema.postgres.sql b/platform-api/internal/database/schema.postgres.sql index 2a7b2eeb0f..816632c93d 100644 --- a/platform-api/internal/database/schema.postgres.sql +++ b/platform-api/internal/database/schema.postgres.sql @@ -407,7 +407,7 @@ CREATE TABLE IF NOT EXISTS api_portals ( display_name VARCHAR(255) NOT NULL, description VARCHAR(1023), url VARCHAR(500), - workflow_status VARCHAR(20) NOT NULL DEFAULT 'pending', + status VARCHAR(20) NOT NULL DEFAULT 'pending', auth_type VARCHAR(20) NOT NULL, auth_configuration BYTEA NOT NULL, metadata BYTEA NOT NULL, diff --git a/platform-api/internal/database/schema.sqlite.sql b/platform-api/internal/database/schema.sqlite.sql index e29787de79..7469009df0 100644 --- a/platform-api/internal/database/schema.sqlite.sql +++ b/platform-api/internal/database/schema.sqlite.sql @@ -407,7 +407,7 @@ CREATE TABLE IF NOT EXISTS api_portals ( display_name VARCHAR(255) NOT NULL, description VARCHAR(1023), url VARCHAR(500), - workflow_status VARCHAR(20) NOT NULL DEFAULT 'pending', + status VARCHAR(20) NOT NULL DEFAULT 'pending', auth_type VARCHAR(20) NOT NULL, auth_configuration BLOB NOT NULL, metadata BLOB NOT NULL, diff --git a/platform-api/internal/database/schema.sqlserver.sql b/platform-api/internal/database/schema.sqlserver.sql index b635256289..898cb29209 100644 --- a/platform-api/internal/database/schema.sqlserver.sql +++ b/platform-api/internal/database/schema.sqlserver.sql @@ -459,7 +459,7 @@ CREATE TABLE dbo.api_portals ( display_name VARCHAR(255) NOT NULL, description VARCHAR(1023), url VARCHAR(500), - workflow_status VARCHAR(20) NOT NULL DEFAULT 'pending', + status VARCHAR(20) NOT NULL DEFAULT 'pending', auth_type VARCHAR(20) NOT NULL, auth_configuration VARBINARY(MAX) NOT NULL, metadata VARBINARY(MAX) NOT NULL, diff --git a/platform-api/internal/model/api_portal.go b/platform-api/internal/model/api_portal.go index 20df6a848a..5f16e6eaf9 100644 --- a/platform-api/internal/model/api_portal.go +++ b/platform-api/internal/model/api_portal.go @@ -40,7 +40,7 @@ type APIPortal struct { Name string `json:"name" db:"display_name"` Description string `json:"description,omitempty" db:"description"` URL string `json:"url,omitempty" db:"url"` - WorkflowStatus string `json:"workflowStatus" db:"workflow_status"` + Status string `json:"status" db:"status"` AuthType string `json:"authType" db:"auth_type"` AuthConfig map[string]interface{} `json:"authConfig,omitempty" db:"auth_configuration"` Metadata map[string]interface{} `json:"metadata,omitempty" db:"metadata"` @@ -57,15 +57,15 @@ func (APIPortal) TableName() string { // IsPending returns true if the portal is still being provisioned or activated. func (p *APIPortal) IsPending() bool { - return p.WorkflowStatus == constants.APIPortalWorkflowStatusPending + return p.Status == constants.APIPortalStatusPending } // IsActive returns true if the portal is reachable and functional. func (p *APIPortal) IsActive() bool { - return p.WorkflowStatus == constants.APIPortalWorkflowStatusActive + return p.Status == constants.APIPortalStatusActive } // IsFailed returns true if provisioning or a subsequent health check has failed. func (p *APIPortal) IsFailed() bool { - return p.WorkflowStatus == constants.APIPortalWorkflowStatusFailed + return p.Status == constants.APIPortalStatusFailed } diff --git a/platform-api/internal/repository/api_portal.go b/platform-api/internal/repository/api_portal.go index c100bc0766..1991c3601b 100644 --- a/platform-api/internal/repository/api_portal.go +++ b/platform-api/internal/repository/api_portal.go @@ -42,7 +42,7 @@ func NewAPIPortalRepo(db *database.DB) APIPortalRepository { // apiPortalSelectColumns are the api_portals columns selected in every query, in scan order. const apiPortalSelectColumns = ` uuid, organization_uuid, handle, display_name, description, url, - workflow_status, auth_type, auth_configuration, metadata, + status, auth_type, auth_configuration, metadata, created_by, updated_by, created_at, updated_at ` @@ -55,7 +55,7 @@ func scanAPIPortalRow(scanner interface { var authConfigBytes, metadataBytes []byte if err := scanner.Scan( &portal.ID, &portal.OrganizationID, &portal.Handle, &portal.Name, &description, &url, - &portal.WorkflowStatus, &portal.AuthType, &authConfigBytes, &metadataBytes, + &portal.Status, &portal.AuthType, &authConfigBytes, &metadataBytes, &createdBy, &updatedBy, &portal.CreatedAt, &portal.UpdatedAt, ); err != nil { return nil, err @@ -122,13 +122,13 @@ func (r *APIPortalRepo) Create(portal *model.APIPortal) error { } query := ` INSERT INTO api_portals (uuid, organization_uuid, handle, display_name, description, url, - workflow_status, auth_type, auth_configuration, metadata, + status, auth_type, auth_configuration, metadata, created_by, updated_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` _, err = r.db.Exec(r.db.Rebind(query), portal.ID, portal.OrganizationID, portal.Handle, portal.Name, portal.Description, portal.URL, - portal.WorkflowStatus, portal.AuthType, authConfigBytes, metadataBytes, + portal.Status, portal.AuthType, authConfigBytes, metadataBytes, portal.CreatedBy, portal.UpdatedBy, portal.CreatedAt, portal.UpdatedAt, ) return err @@ -237,13 +237,13 @@ func (r *APIPortalRepo) Update(portal *model.APIPortal) error { } query := ` UPDATE api_portals - SET display_name = ?, description = ?, url = ?, workflow_status = ?, + SET display_name = ?, description = ?, url = ?, status = ?, auth_type = ?, auth_configuration = ?, metadata = ?, updated_by = ?, updated_at = ? WHERE uuid = ? AND organization_uuid = ? ` result, err := r.db.Exec(r.db.Rebind(query), - portal.Name, portal.Description, portal.URL, portal.WorkflowStatus, + portal.Name, portal.Description, portal.URL, portal.Status, portal.AuthType, authConfigBytes, metadataBytes, portal.UpdatedBy, portal.UpdatedAt, portal.ID, portal.OrganizationID, diff --git a/platform-api/internal/repository/api_portal_test.go b/platform-api/internal/repository/api_portal_test.go index 9adc206077..fcb4ee0530 100644 --- a/platform-api/internal/repository/api_portal_test.go +++ b/platform-api/internal/repository/api_portal_test.go @@ -50,7 +50,7 @@ func newTestAPIPortal(uuid, orgUUID, handle string) *model.APIPortal { Name: "Portal " + handle, Description: "test portal", URL: "https://" + handle + ".example.com", - WorkflowStatus: constants.APIPortalWorkflowStatusPending, + Status: constants.APIPortalStatusPending, AuthType: constants.APIPortalAuthTypeLocal, AuthConfig: map[string]interface{}{"foo": "bar"}, CreatedBy: "tester", @@ -348,7 +348,7 @@ func TestAPIPortalRepo_Update(t *testing.T) { portal.Name = "Renamed" portal.Description = "new description" portal.URL = "https://renamed.example.com" - portal.WorkflowStatus = constants.APIPortalWorkflowStatusActive + portal.Status = constants.APIPortalStatusActive portal.AuthType = constants.APIPortalAuthTypeOAuth2 portal.AuthConfig = map[string]interface{}{"stsTokenUrl": "https://sts/x"} portal.UpdatedBy = "editor" @@ -367,7 +367,7 @@ func TestAPIPortalRepo_Update(t *testing.T) { } if got.Name != "Renamed" || got.Description != "new description" || got.URL != "https://renamed.example.com" || - got.WorkflowStatus != constants.APIPortalWorkflowStatusActive || + got.Status != constants.APIPortalStatusActive || got.AuthType != constants.APIPortalAuthTypeOAuth2 || got.UpdatedBy != "editor" { t.Errorf("mutable fields not persisted; got %+v", got) diff --git a/platform-api/internal/service/api_portal.go b/platform-api/internal/service/api_portal.go index 0dec26c3b1..4f0bb0d157 100644 --- a/platform-api/internal/service/api_portal.go +++ b/platform-api/internal/service/api_portal.go @@ -330,7 +330,7 @@ func (s *APIPortalService) CreateAPIPortal(req *CreateAPIPortalRequest, orgID, c Name: name, Description: strings.TrimSpace(req.Description), URL: portalURL, - WorkflowStatus: constants.APIPortalWorkflowStatusActive, + Status: constants.APIPortalStatusActive, AuthType: authType, AuthConfig: authConfig, Metadata: req.Metadata, diff --git a/platform-api/internal/service/api_portal_test.go b/platform-api/internal/service/api_portal_test.go index 8b7fb71753..5d12cc0a4c 100644 --- a/platform-api/internal/service/api_portal_test.go +++ b/platform-api/internal/service/api_portal_test.go @@ -172,10 +172,10 @@ func TestAPIPortalService_CreateAPIPortal_HappyPath(t *testing.T) { if got == nil || got.Handle != "acme" || got.Name != "Acme Portal" { t.Errorf("returned portal wrong shape: %+v", got) } - // OSS registers a portal that's already running; workflowStatus is always + // OSS registers a portal that's already running; status is always // active from create, and is not exposed on the wire. - if got.WorkflowStatus != constants.APIPortalWorkflowStatusActive { - t.Errorf("default workflowStatus: want active, got %q", got.WorkflowStatus) + if got.Status != constants.APIPortalStatusActive { + t.Errorf("default status: want active, got %q", got.Status) } if got.ID == "" { t.Error("expected generated UUID, got empty") @@ -352,8 +352,8 @@ func TestAPIPortalService_UpdateAPIPortal_SwitchOAuth2ToLocal(t *testing.T) { existing := &model.APIPortal{ ID: "p1", Handle: "acme", OrganizationID: "org-1", Name: "Acme", URL: "https://acme.example.com", - WorkflowStatus: constants.APIPortalWorkflowStatusActive, - AuthType: constants.APIPortalAuthTypeOAuth2, + Status: constants.APIPortalStatusActive, + AuthType: constants.APIPortalAuthTypeOAuth2, AuthConfig: map[string]interface{}{ "stsTokenUrl": "https://sts.example.com/token", "clientId": "abc", @@ -382,7 +382,7 @@ func TestAPIPortalService_UpdateAPIPortal_SwitchOAuth2ToLocal(t *testing.T) { func TestAPIPortalService_UpdateAPIPortal_InvalidURLRejected(t *testing.T) { existing := &model.APIPortal{ ID: "p1", Handle: "acme", OrganizationID: "org-1", - Name: "Acme", WorkflowStatus: constants.APIPortalWorkflowStatusActive, + Name: "Acme", Status: constants.APIPortalStatusActive, AuthType: constants.APIPortalAuthTypeLocal, } svc := newTestAPIPortalService(t, @@ -477,8 +477,8 @@ func TestAPIPortalService_UpdateAPIPortal_HappyPath(t *testing.T) { existing := &model.APIPortal{ ID: "p1", Handle: "acme", OrganizationID: "org-1", Name: "old", URL: "https://acme.example.com", - WorkflowStatus: constants.APIPortalWorkflowStatusPending, - AuthType: constants.APIPortalAuthTypeLocal, + Status: constants.APIPortalStatusPending, + AuthType: constants.APIPortalAuthTypeLocal, } portalRepo := &mockAPIPortalRepository{getResult: existing} auditRepo := &mockAPIPortalAuditRepository{} @@ -518,8 +518,8 @@ func TestAPIPortalService_UpdateAPIPortal_PartialUpdate(t *testing.T) { existing := &model.APIPortal{ ID: "p1", Handle: "acme", OrganizationID: "org-1", Name: "keep", URL: "https://keep.example.com", - WorkflowStatus: constants.APIPortalWorkflowStatusActive, - AuthType: constants.APIPortalAuthTypeLocal, + Status: constants.APIPortalStatusActive, + AuthType: constants.APIPortalAuthTypeLocal, } svc := newTestAPIPortalService(t, &mockAPIPortalRepository{getResult: existing}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) // Only Description supplied; everything else must remain unchanged. From aa1cdf8feca7904f16025175629d5164686211cf Mon Sep 17 00:00:00 2001 From: dushaniw Date: Tue, 25 Aug 2026 15:45:17 +0530 Subject: [PATCH 17/25] Tighten the outbound token-endpoint call: shape check + no redirects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two narrow changes to the outbound `client_credentials` path so this PR doesn't ship the credential-bearing call without any handling for the obvious footguns, while leaving deeper egress work to the shared outbound HTTP client feature that's coming later. - validateAPIPortalSTSTokenURL runs at write time via validateAPIPortalAuthConfig: rejects empty, non-absolute, non-`https`, and unparseable values on `authConfig.stsTokenUrl`. Same shape rules the portal URL already uses. Host-based restrictions (loopback / private / metadata literal blocks, DNS resolve-and-recheck) are deliberately NOT added — a legitimate on-prem / local deployment can have its STS at https://localhost or a private-range address, so those controls need to be operator-aware and belong to the planned shared outbound HTTP client feature. - newClientCredentialsAuthProvider's default *http.Client is now built with CheckRedirect that returns http.ErrUseLastResponse. A 3xx from the STS on the token endpoint isn't a legitimate part of the client-credentials flow; following it would re-send client_id + client_secret to the redirect target. The provider now surfaces the 3xx as a non-2xx error. Tests: - Table-driven negative cases on stsTokenUrl (empty, http scheme, missing scheme, file/javascript schemes, scheme-only) and a positive control (valid https URL accepted). - TestClientCredentialsAuthProvider_DefaultClientRefusesRedirects stands up an httptest server that returns 302 and asserts AuthorizationHeader errors instead of following. Co-Authored-By: Claude Opus 4.7 (1M context) --- platform-api/internal/service/api_portal.go | 41 ++++++++++++ .../internal/service/api_portal_auth.go | 13 +++- .../internal/service/api_portal_auth_test.go | 22 ++++++ .../internal/service/api_portal_test.go | 67 +++++++++++++++++++ 4 files changed, 142 insertions(+), 1 deletion(-) diff --git a/platform-api/internal/service/api_portal.go b/platform-api/internal/service/api_portal.go index 4f0bb0d157..4f5f5531d1 100644 --- a/platform-api/internal/service/api_portal.go +++ b/platform-api/internal/service/api_portal.go @@ -66,6 +66,36 @@ func validateAPIPortalURL(raw string) (string, error) { return u.String(), nil } +// validateAPIPortalSTSTokenURL runs the same base checks as validateAPIPortalURL +// on `authConfig.stsTokenUrl` — the target of the outbound `client_credentials` +// token request that carries clientSecret. Empty is rejected because the +// oauth2 grant needs an endpoint; a required-field check upstream also +// enforces this, but keeping it here means callers see a clear message. +// +// Host-based restrictions (loopback / private / link-local / metadata +// literals, DNS-based resolve-and-recheck) are intentionally NOT enforced +// here — a legitimate local / on-prem deployment can have its STS at +// https://localhost:9443 or a private-range address. Operator-aware egress +// controls are planned as a shared outbound HTTP client feature; the same +// deferral applies to `validateAPIPortalURL`. +func validateAPIPortalSTSTokenURL(raw string) error { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return apperror.ValidationFailed.New("The stsTokenUrl field is required.") + } + u, err := url.Parse(trimmed) + if err != nil { + return apperror.ValidationFailed.New("The stsTokenUrl field is not a valid URL.") + } + if !u.IsAbs() || u.Host == "" { + return apperror.ValidationFailed.New("The stsTokenUrl field must be an absolute URL with a host.") + } + if u.Scheme != "https" { + return apperror.ValidationFailed.New("The stsTokenUrl field must use the https scheme.") + } + return nil +} + // APIPortalService encapsulates business logic for the /api-portals resource. // The handler layer translates OpenAPI-generated request/response DTOs into // the service's own request structs so the service stays independent of the @@ -146,6 +176,17 @@ func validateAPIPortalAuthConfig(authType string, cfg map[string]interface{}) er fmt.Sprintf("authConfig field %q is not supported for authType %q.", k, authType)) } } + // stsTokenUrl is the target of the outbound client_credentials + // request that carries clientSecret, so it gets a stricter shape + // check than a generic string. Ciphertext (already-encrypted, from + // a merge path) has never occupied this key — clientSecret is the + // only encrypted field — so the value here is a plaintext URL and + // the parse-and-check is safe. See validateAPIPortalSTSTokenURL. + if raw, ok := cfg[constants.APIPortalAuthConfigKeySTSTokenURL].(string); ok { + if err := validateAPIPortalSTSTokenURL(raw); err != nil { + return err + } + } return nil } return apperror.ValidationFailed.New( diff --git a/platform-api/internal/service/api_portal_auth.go b/platform-api/internal/service/api_portal_auth.go index 60ef82bdee..64b976cf5d 100644 --- a/platform-api/internal/service/api_portal_auth.go +++ b/platform-api/internal/service/api_portal_auth.go @@ -156,7 +156,18 @@ type clientCredentialsAuthProvider struct { func newClientCredentialsAuthProvider(tokenURL, clientID, clientSecret string, hc *http.Client) *clientCredentialsAuthProvider { if hc == nil { - hc = &http.Client{Timeout: 15 * time.Second} + // Default client: 15s timeout, and REJECT redirects. A 3xx from the + // STS on the token endpoint isn't a legitimate part of the client- + // credentials flow — following it would re-send the client_id + + // client_secret to a redirect target chosen by whatever answered + // the token endpoint. Return the 3xx response as-is so + // AuthorizationHeader sees it as a non-2xx and errors out. + hc = &http.Client{ + Timeout: 15 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } } return &clientCredentialsAuthProvider{ tokenURL: tokenURL, diff --git a/platform-api/internal/service/api_portal_auth_test.go b/platform-api/internal/service/api_portal_auth_test.go index 412b5f9960..2376a446a0 100644 --- a/platform-api/internal/service/api_portal_auth_test.go +++ b/platform-api/internal/service/api_portal_auth_test.go @@ -250,6 +250,28 @@ func TestClientCredentialsAuthProvider_NonSuccessStatusReturnsError(t *testing.T } } +func TestClientCredentialsAuthProvider_DefaultClientRefusesRedirects(t *testing.T) { + // The default *http.Client the provider builds when caller passes hc=nil + // must NOT follow redirects. A 3xx returned by the STS on the token + // endpoint isn't a legitimate part of the client-credentials flow; + // following it would re-send client_id + client_secret to whatever host + // the redirect names. We treat the 3xx as a non-2xx and surface an + // error. + redirecting := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "https://elsewhere.example.com/oauth2/token", http.StatusFound) + })) + t.Cleanup(redirecting.Close) + + p := newClientCredentialsAuthProvider(redirecting.URL, "id", "secret", nil) + _, err := p.AuthorizationHeader(context.Background()) + if err == nil { + t.Fatal("expected error when STS returns a redirect; got nil (client followed the redirect)") + } + if !strings.Contains(err.Error(), "302") { + t.Errorf("error should surface the 3xx status code; got %v", err) + } +} + func TestClientCredentialsAuthProvider_ConcurrentCallsIssueSingleFetch(t *testing.T) { sts := newSTSStub() t.Cleanup(sts.Close) diff --git a/platform-api/internal/service/api_portal_test.go b/platform-api/internal/service/api_portal_test.go index 5d12cc0a4c..ce6b9c76b1 100644 --- a/platform-api/internal/service/api_portal_test.go +++ b/platform-api/internal/service/api_portal_test.go @@ -345,6 +345,73 @@ func TestAPIPortalService_CreateAPIPortal_EmptyURLRejected(t *testing.T) { } } +func TestAPIPortalService_CreateAPIPortal_STSTokenURL_Rejected(t *testing.T) { + // stsTokenUrl is the outbound target of a client_credentials request + // carrying clientSecret; input-time checks enforce the same shape rules + // as the portal URL (absolute, host, https, non-empty). Host-based + // egress controls (loopback / private / metadata literal blocks, + // DNS-based resolve-and-recheck) belong in an operator-aware shared + // outbound HTTP client; local / on-prem deployments legitimately need + // https://localhost or private-range addresses here. + cases := []struct { + name string + url string + }{ + {"empty", ""}, + {"http_scheme", "http://sts.example.com/oauth2/token"}, + {"missing_scheme", "sts.example.com/oauth2/token"}, + {"file_scheme", "file:///etc/passwd"}, + {"javascript_scheme", "javascript:alert(1)"}, + {"scheme_only", "https://"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc := newTestAPIPortalService(t, + &mockAPIPortalRepository{}, + &mockAPIPortalOrgRepository{result: &model.Organization{}}, + &mockAPIPortalAuditRepository{}, + ) + _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ + Handle: "acme", + Name: "Acme", + URL: "https://acme.example.com", + AuthType: constants.APIPortalAuthTypeOAuth2, + AuthConfig: map[string]interface{}{ + "stsTokenUrl": tc.url, + "clientId": "abc", + "clientSecret": "s3cr3t", + }, + }, "org-1", "user-1") + if err == nil || !apperror.ValidationFailed.Is(err) { + t.Errorf("want ValidationFailed for stsTokenUrl=%q, got %v", tc.url, err) + } + }) + } +} + +func TestAPIPortalService_CreateAPIPortal_STSTokenURL_Accepted(t *testing.T) { + // Positive control: a reachable-shaped https URL is accepted. + svc := newTestAPIPortalService(t, + &mockAPIPortalRepository{}, + &mockAPIPortalOrgRepository{result: &model.Organization{}}, + &mockAPIPortalAuditRepository{}, + ) + _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ + Handle: "acme", + Name: "Acme", + URL: "https://acme.example.com", + AuthType: constants.APIPortalAuthTypeOAuth2, + AuthConfig: map[string]interface{}{ + "stsTokenUrl": "https://sts.example.com/oauth2/token", + "clientId": "abc", + "clientSecret": "s3cr3t", + }, + }, "org-1", "user-1") + if err != nil { + t.Fatalf("valid stsTokenUrl rejected: %v", err) + } +} + func TestAPIPortalService_UpdateAPIPortal_SwitchOAuth2ToLocal(t *testing.T) { // Regression: switching authType from oauth2 to local must clear the stored // oauth2 authConfig — otherwise the post-mutation validator rejects the From 78e2dc674018fa5e3ec88e629905ae860c2e2e15 Mon Sep 17 00:00:00 2001 From: dushaniw Date: Tue, 25 Aug 2026 16:14:48 +0530 Subject: [PATCH 18/25] Enforce redirect refusal on caller-supplied *http.Client too The prior commit only set CheckRedirect when the caller passed nil for hc; a callsite (test or otherwise) that handed in its own client would bypass the policy. Copy the caller's client and set CheckRedirect on the copy so the token-endpoint call refuses 3xx regardless of what shell was passed in, without mutating the caller's shared instance. Test extended to cover both paths: default-client and caller-supplied. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../internal/service/api_portal_auth.go | 29 +++++++------- .../internal/service/api_portal_auth_test.go | 38 ++++++++++++------- 2 files changed, 41 insertions(+), 26 deletions(-) diff --git a/platform-api/internal/service/api_portal_auth.go b/platform-api/internal/service/api_portal_auth.go index 64b976cf5d..6e0f8e8ee2 100644 --- a/platform-api/internal/service/api_portal_auth.go +++ b/platform-api/internal/service/api_portal_auth.go @@ -155,25 +155,28 @@ type clientCredentialsAuthProvider struct { } func newClientCredentialsAuthProvider(tokenURL, clientID, clientSecret string, hc *http.Client) *clientCredentialsAuthProvider { + // Always REJECT redirects on the token-endpoint call. A 3xx from the STS + // on this endpoint isn't a legitimate part of the client-credentials + // flow — following it would re-send the client_id + client_secret to a + // redirect target chosen by whatever answered. This is enforced + // regardless of what a caller-supplied client had configured; we copy + // the caller's *http.Client so their instance keeps its own policy for + // any other use. + var client *http.Client if hc == nil { - // Default client: 15s timeout, and REJECT redirects. A 3xx from the - // STS on the token endpoint isn't a legitimate part of the client- - // credentials flow — following it would re-send the client_id + - // client_secret to a redirect target chosen by whatever answered - // the token endpoint. Return the 3xx response as-is so - // AuthorizationHeader sees it as a non-2xx and errors out. - hc = &http.Client{ - Timeout: 15 * time.Second, - CheckRedirect: func(*http.Request, []*http.Request) error { - return http.ErrUseLastResponse - }, - } + client = &http.Client{Timeout: 15 * time.Second} + } else { + copied := *hc + client = &copied + } + client.CheckRedirect = func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse } return &clientCredentialsAuthProvider{ tokenURL: tokenURL, clientID: clientID, clientSecret: clientSecret, - httpClient: hc, + httpClient: client, } } diff --git a/platform-api/internal/service/api_portal_auth_test.go b/platform-api/internal/service/api_portal_auth_test.go index 2376a446a0..5eae3cc188 100644 --- a/platform-api/internal/service/api_portal_auth_test.go +++ b/platform-api/internal/service/api_portal_auth_test.go @@ -250,25 +250,37 @@ func TestClientCredentialsAuthProvider_NonSuccessStatusReturnsError(t *testing.T } } -func TestClientCredentialsAuthProvider_DefaultClientRefusesRedirects(t *testing.T) { - // The default *http.Client the provider builds when caller passes hc=nil - // must NOT follow redirects. A 3xx returned by the STS on the token - // endpoint isn't a legitimate part of the client-credentials flow; - // following it would re-send client_id + client_secret to whatever host - // the redirect names. We treat the 3xx as a non-2xx and surface an - // error. +func TestClientCredentialsAuthProvider_RefusesRedirects(t *testing.T) { + // A 3xx returned by the STS on the token endpoint isn't a legitimate + // part of the client-credentials flow; following it would re-send + // client_id + client_secret to whatever host the redirect names. We + // treat the 3xx as a non-2xx and surface an error. Enforced on the + // default *http.Client the provider builds, AND on a client the caller + // supplies — so a test / callsite that hands in its own client can't + // accidentally opt out. redirecting := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "https://elsewhere.example.com/oauth2/token", http.StatusFound) })) t.Cleanup(redirecting.Close) - p := newClientCredentialsAuthProvider(redirecting.URL, "id", "secret", nil) - _, err := p.AuthorizationHeader(context.Background()) - if err == nil { - t.Fatal("expected error when STS returns a redirect; got nil (client followed the redirect)") + // Nil hc → provider builds its own default. Should refuse. + pDefault := newClientCredentialsAuthProvider(redirecting.URL, "id", "secret", nil) + if _, err := pDefault.AuthorizationHeader(context.Background()); err == nil || + !strings.Contains(err.Error(), "302") { + t.Fatalf("default client should refuse redirect; got err=%v", err) + } + + // Caller-supplied hc that WOULD follow redirects by default. Provider + // must still refuse — meaning its own copy has CheckRedirect wired, + // and the caller's original client remains untouched. + callerClient := &http.Client{Timeout: 5 * time.Second} + pCaller := newClientCredentialsAuthProvider(redirecting.URL, "id", "secret", callerClient) + if _, err := pCaller.AuthorizationHeader(context.Background()); err == nil || + !strings.Contains(err.Error(), "302") { + t.Errorf("caller-supplied client should also refuse redirect; got err=%v", err) } - if !strings.Contains(err.Error(), "302") { - t.Errorf("error should surface the 3xx status code; got %v", err) + if callerClient.CheckRedirect != nil { + t.Error("caller's original *http.Client was mutated; expected the provider to copy it") } } From 320f532bf76321ed173f5e8ef3fb65f050bdb16b Mon Sep 17 00:00:00 2001 From: dushaniw Date: Sat, 29 Aug 2026 13:42:01 +0530 Subject: [PATCH 19/25] feat(api-portals): expose APIPortals capability on pdk.Deps Refactor APIPortalService to speak the OpenAPI-generated api.* types directly so it satisfies a new pdk.APIPortals interface by shape, matching the pattern used for Gateways and Projects. External plugins (specifically the cloud managed-portals plugin) can now consume portal CRUD via deps.APIPortals with no adapter code. - Move DTO<->model translation into internal/service/api_portal_translate.go - Service Create/Get/List/Update/Delete now take api.* types; internal request/response structs deleted - Handler shrinks to thin wrappers; httputil import path fixed - Add APIPortals interface + field on pdk.Deps - Wire deps.APIPortals = apiPortalService in StartPlatformAPIServer --- platform-api/internal/handler/api_portal.go | 192 ++-------------- platform-api/internal/server/server.go | 9 +- platform-api/internal/service/api_portal.go | 135 +++++------ .../internal/service/api_portal_test.go | 217 +++++++++++++----- .../internal/service/api_portal_translate.go | 168 ++++++++++++++ platform-api/pdk/deps.go | 36 ++- 6 files changed, 432 insertions(+), 325 deletions(-) create mode 100644 platform-api/internal/service/api_portal_translate.go diff --git a/platform-api/internal/handler/api_portal.go b/platform-api/internal/handler/api_portal.go index f1fc1314da..7b76aa97a0 100644 --- a/platform-api/internal/handler/api_portal.go +++ b/platform-api/internal/handler/api_portal.go @@ -28,16 +28,16 @@ import ( "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/middleware" - "github.com/wso2/api-platform/platform-api/internal/model" "github.com/wso2/api-platform/platform-api/internal/router" "github.com/wso2/api-platform/platform-api/internal/service" - "github.com/wso2/go-httpkit/httputil" + "github.com/wso2/api-platform/httpkit/httputil" ) // APIPortalHandler exposes /api-portals CRUD. The generated OpenAPI types -// (api.CreateApiPortalRequest / api.ApiPortalResponse / …) are the wire contract; -// this file only translates between them and the service layer. +// (api.CreateApiPortalRequest / api.ApiPortalResponse / …) are the wire contract +// AND the service-layer contract — the service speaks in these directly so its +// methods also satisfy pdk.APIPortals for plugins. type APIPortalHandler struct { svc *service.APIPortalService identity *service.IdentityService @@ -66,22 +66,13 @@ func (h *APIPortalHandler) CreateAPIPortal(w http.ResponseWriter, r *http.Reques return err } - svcReq := &service.CreateAPIPortalRequest{ - Handle: strings.TrimSpace(req.Handle), - Name: strings.TrimSpace(req.Name), - Description: deref(req.Description), - URL: req.Url, - AuthType: string(req.AuthType), - AuthConfig: authConfigStructToMap(req.AuthConfig), - Metadata: derefMetadata(req.Metadata), - } - portal, err := h.svc.CreateAPIPortal(svcReq, orgID, createdBy) + resp, err := h.svc.CreateAPIPortal(&req, orgID, createdBy) if err != nil { - return serviceError(err, fmt.Sprintf("failed to create api portal %q for org %s by user %s", svcReq.Handle, orgID, createdBy)) + return serviceError(err, fmt.Sprintf("failed to create api portal %q for org %s by user %s", req.Handle, orgID, createdBy)) } - setLocation(w, "api-portals", portal.Handle) - httputil.WriteJSON(w, http.StatusCreated, modelToAPIPortalResponse(portal)) + setLocation(w, "api-portals", derefStr(resp.Handle)) + httputil.WriteJSON(w, http.StatusCreated, resp) return nil } @@ -97,11 +88,11 @@ func (h *APIPortalHandler) GetAPIPortal(w http.ResponseWriter, r *http.Request) return apperror.ValidationFailed.New("API Portal ID is required") } - portal, err := h.svc.GetAPIPortal(handle, orgID) + resp, err := h.svc.GetAPIPortal(handle, orgID) if err != nil { return serviceError(err, fmt.Sprintf("failed to get api portal %q in org %s", handle, orgID)) } - httputil.WriteJSON(w, http.StatusOK, modelToAPIPortalResponse(portal)) + httputil.WriteJSON(w, http.StatusOK, resp) return nil } @@ -112,13 +103,13 @@ func (h *APIPortalHandler) ListAPIPortals(w http.ResponseWriter, r *http.Request return apperror.Unauthorized.New().WithLogMessage("organization claim not found in token") } - opts := service.APIPortalListOptions{ListOptions: parseListOptions(r)} + opts := parseListOptions(r) - resp, err := h.svc.ListAPIPortals(orgID, opts) + resp, err := h.svc.ListAPIPortals(orgID, opts.Limit, opts.Offset, opts.SortBy, opts.SortOrder, opts.Search) if err != nil { return serviceError(err, fmt.Sprintf("failed to list api portals for org %s", orgID)) } - httputil.WriteJSON(w, http.StatusOK, apiPortalListResponse(resp)) + httputil.WriteJSON(w, http.StatusOK, resp) return nil } @@ -144,23 +135,11 @@ func (h *APIPortalHandler) UpdateAPIPortal(w http.ResponseWriter, r *http.Reques return err } - svcReq := &service.UpdateAPIPortalRequest{ - Name: req.Name, - Description: req.Description, - URL: req.Url, - AuthConfig: authConfigStructToMap(req.AuthConfig), - Metadata: derefMetadata(req.Metadata), - } - if req.AuthType != nil { - v := string(*req.AuthType) - svcReq.AuthType = &v - } - - portal, err := h.svc.UpdateAPIPortal(handle, svcReq, orgID, updatedBy) + resp, err := h.svc.UpdateAPIPortal(handle, &req, orgID, updatedBy) if err != nil { return serviceError(err, fmt.Sprintf("failed to update api portal %q in org %s by user %s", handle, orgID, updatedBy)) } - httputil.WriteJSON(w, http.StatusOK, modelToAPIPortalResponse(portal)) + httputil.WriteJSON(w, http.StatusOK, resp) return nil } @@ -198,146 +177,11 @@ func (h *APIPortalHandler) RegisterRoutes(mux router.Router) { mux.HandleFunc("DELETE "+base+"/{apiPortalId}", middleware.MapErrors(h.slogger, h.DeleteAPIPortal)) } -// --- translation helpers --- - -func deref(p *string) string { +// derefStr returns the pointed-to string or "" when nil. Local helper used by +// setLocation to source the Location header from the api-generated response. +func derefStr(p *string) string { if p == nil { return "" } return *p } - -// derefMetadata converts the generated Metadata type (a map alias) into a plain -// map[string]interface{} for the service layer, dropping the nil pointer. -func derefMetadata(m *api.ApiPortalMetadata) map[string]interface{} { - if m == nil { - return nil - } - return map[string]interface{}(*m) -} - -// authConfigStructToMap flattens the generated ApiPortalAuthConfig struct into -// the map shape the service layer expects. Nil pointer fields are dropped so -// downstream validation sees "missing" (rather than "present but empty"). -func authConfigStructToMap(c *api.ApiPortalAuthConfig) map[string]interface{} { - if c == nil { - return nil - } - out := map[string]interface{}{} - if c.StsTokenUrl != nil { - out[constants.APIPortalAuthConfigKeySTSTokenURL] = *c.StsTokenUrl - } - if c.ClientId != nil { - out[constants.APIPortalAuthConfigKeyClientID] = *c.ClientId - } - if c.ClientSecret != nil { - out[constants.APIPortalAuthConfigKeyClientSecret] = *c.ClientSecret - } - return out -} - -// stripSensitiveAuthConfig deletes any keys that carry secret material before -// the config leaves the server. Belt-and-suspenders alongside the OAS -// `writeOnly: true` marker on ClientSecret — even if a client somehow round- -// trips a plaintext secret through storage (e.g. during migration or if the -// storage-encrypt step is ever skipped), the response strip guarantees it -// never appears on the wire. -func stripSensitiveAuthConfig(cfg map[string]interface{}) map[string]interface{} { - if cfg == nil { - return nil - } - out := make(map[string]interface{}, len(cfg)) - for k, v := range cfg { - out[k] = v - } - for _, key := range constants.APIPortalAuthConfigSensitiveKeys { - delete(out, key) - } - return out -} - -// mapToAuthConfigStruct rebuilds the generated struct from the stored map for -// response serialization. Sensitive keys are stripped first, so the generated -// ClientSecret pointer stays nil (and — since it's marked omitempty — won't -// appear in the JSON output). -func mapToAuthConfigStruct(m map[string]interface{}) *api.ApiPortalAuthConfig { - stripped := stripSensitiveAuthConfig(m) - if stripped == nil { - return nil - } - c := &api.ApiPortalAuthConfig{} - if v, ok := stripped[constants.APIPortalAuthConfigKeySTSTokenURL].(string); ok && v != "" { - s := v - c.StsTokenUrl = &s - } - if v, ok := stripped[constants.APIPortalAuthConfigKeyClientID].(string); ok && v != "" { - s := v - c.ClientId = &s - } - // ClientSecret is intentionally never populated on the response side. - return c -} - -func modelToAPIPortalResponse(p *model.APIPortal) *api.ApiPortalResponse { - if p == nil { - return nil - } - id := p.Handle - handle := p.Handle - createdAt := p.CreatedAt - updatedAt := p.UpdatedAt - - resp := &api.ApiPortalResponse{ - Id: &id, - Handle: &handle, - Name: p.Name, - Url: p.URL, - AuthType: api.ApiPortalResponseAuthType(p.AuthType), - CreatedAt: &createdAt, - UpdatedAt: &updatedAt, - } - if p.Description != "" { - desc := p.Description - resp.Description = &desc - } - if p.AuthConfig != nil { - resp.AuthConfig = mapToAuthConfigStruct(p.AuthConfig) - } - if p.Metadata != nil { - m := api.ApiPortalMetadata(p.Metadata) - resp.Metadata = &m - } - return resp -} - -func modelToAPIPortalListItem(p *model.APIPortal) api.ApiPortalListItem { - item := api.ApiPortalListItem{ - Id: p.Handle, - Handle: p.Handle, - Name: p.Name, - Url: p.URL, - AuthType: api.ApiPortalListItemAuthType(p.AuthType), - CreatedAt: p.CreatedAt, - } - if p.Description != "" { - desc := p.Description - item.Description = &desc - } - return item -} - -func apiPortalListResponse(resp *service.APIPortalListResponse) *api.ApiPortalListResponse { - out := &api.ApiPortalListResponse{ - Count: resp.Count, - List: make([]api.ApiPortalListItem, 0, len(resp.List)), - Pagination: api.Pagination{ - Total: resp.Pagination.Total, - Offset: resp.Pagination.Offset, - Limit: resp.Pagination.Limit, - }, - } - for _, p := range resp.List { - out.List = append(out.List, modelToAPIPortalListItem(p)) - } - return out -} diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index 41f9c18451..02c978c92e 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -441,10 +441,11 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, // assignment itself is the compile-time contract check: if a service method // signature drifts from the pdk interface, this stops building. pdkDeps := &pdk.Deps{ - Gateways: gatewayService, - Projects: projectService, - Config: cfg, - Logger: slogger, + Gateways: gatewayService, + Projects: projectService, + APIPortals: apiPortalService, + Config: cfg, + Logger: slogger, } wiring, err := initPlugins(slogger, mux, scopeRegistry, pluginDeps, pdkDeps, internalPlugins, externalPlugins) diff --git a/platform-api/internal/service/api_portal.go b/platform-api/internal/service/api_portal.go index 4f5f5531d1..3ecff9d2ea 100644 --- a/platform-api/internal/service/api_portal.go +++ b/platform-api/internal/service/api_portal.go @@ -27,6 +27,7 @@ import ( "github.com/google/uuid" + "github.com/wso2/api-platform/platform-api/api" "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/model" @@ -257,64 +258,26 @@ func copyStringMap(m map[string]interface{}) map[string]interface{} { return out } -// CreateAPIPortalRequest is the service-layer input for creating an API Portal. -// Fields mirror the OpenAPI CreateApiPortalRequest but stay independent of the -// generated types. -type CreateAPIPortalRequest struct { - Handle string - Name string - Description string - URL string - AuthType string - AuthConfig map[string]interface{} - Metadata map[string]interface{} -} - -// UpdateAPIPortalRequest carries mutable fields for a partial update. Pointer -// fields distinguish "not sent" (nil) from "sent as empty" (non-nil, empty). -// Only whitelisted fields are respected here; Handle, ID, OrganizationID, -// CreatedAt, CreatedBy are ignored per the design's immutability rules. -// -// AuthConfig on update uses merge semantics: supplied keys overwrite existing -// keys, missing keys retain their stored values. This lets a caller rotate a -// single field without re-supplying clientSecret (which they can't fetch back -// after it's been stored encrypted). -// -// Metadata on update uses replace semantics: if supplied (non-nil), it fully -// replaces the stored metadata. Callers that want a partial-update on metadata -// should GET, modify, PUT the whole thing. -type UpdateAPIPortalRequest struct { - Name *string - Description *string - URL *string - AuthType *string - AuthConfig map[string]interface{} // when nil, existing preserved; when non-nil, merged in - Metadata map[string]interface{} // when nil, existing preserved; when non-nil, replaces -} - -// APIPortalListOptions bundles the pagination inputs for List. -type APIPortalListOptions struct { - repository.ListOptions -} - -// APIPortalListResponse is the service-layer list result. The handler wraps -// this in the OpenAPI-generated envelope. -type APIPortalListResponse struct { - Count int - List []*model.APIPortal - Pagination PaginationInfo -} - -// PaginationInfo is the {total, offset, limit} triplet returned in list responses. +// PaginationInfo is the {total, offset, limit} triplet used to build the +// list-response envelope in api_portal_translate.go. type PaginationInfo struct { Total int Offset int Limit int } +// deref helpers used by the api-DTO-facing service methods. +func derefStr(p *string) string { + if p == nil { + return "" + } + return *p +} + // CreateAPIPortal validates the request, enforces uniqueness of the handle, -// and inserts a new row scoped to orgID. -func (s *APIPortalService) CreateAPIPortal(req *CreateAPIPortalRequest, orgID, createdBy string) (*model.APIPortal, error) { +// and inserts a new row scoped to orgID. Speaks in api-generated types +// directly so it satisfies the pdk.APIPortals contract by shape. +func (s *APIPortalService) CreateAPIPortal(req *api.CreateApiPortalRequest, orgID, createdBy string) (*api.ApiPortalResponse, error) { if req == nil { return nil, apperror.ValidationFailed.New("The request body is required.") } @@ -325,12 +288,12 @@ func (s *APIPortalService) CreateAPIPortal(req *CreateAPIPortalRequest, orgID, c if err := utils.ValidateHandle(strings.TrimSpace(req.Handle)); err != nil { return nil, err } - authType := strings.TrimSpace(req.AuthType) + authType := strings.TrimSpace(string(req.AuthType)) if !constants.ValidAPIPortalAuthTypes[authType] { return nil, apperror.ValidationFailed.New( fmt.Sprintf("The authType %q is not supported.", authType)) } - portalURL, err := validateAPIPortalURL(req.URL) + portalURL, err := validateAPIPortalURL(req.Url) if err != nil { return nil, err } @@ -339,7 +302,7 @@ func (s *APIPortalService) CreateAPIPortal(req *CreateAPIPortalRequest, orgID, c } // Copy the incoming authConfig so we don't mutate the caller's map when we // encrypt secret fields in place. - authConfig := copyStringMap(req.AuthConfig) + authConfig := copyStringMap(authConfigStructToMap(req.AuthConfig)) if err := validateAPIPortalAuthConfig(authType, authConfig); err != nil { return nil, err } @@ -369,12 +332,12 @@ func (s *APIPortalService) CreateAPIPortal(req *CreateAPIPortalRequest, orgID, c OrganizationID: orgID, Handle: strings.TrimSpace(req.Handle), Name: name, - Description: strings.TrimSpace(req.Description), + Description: strings.TrimSpace(derefStr(req.Description)), URL: portalURL, Status: constants.APIPortalStatusActive, AuthType: authType, AuthConfig: authConfig, - Metadata: req.Metadata, + Metadata: derefAPIPortalMetadata(req.Metadata), CreatedBy: actor, UpdatedBy: actor, } @@ -387,11 +350,11 @@ func (s *APIPortalService) CreateAPIPortal(req *CreateAPIPortalRequest, orgID, c return nil, err } _ = s.auditRepo.Record("CREATE", portal.ID, "api_portal", orgID, actor) - return portal, nil + return ModelToAPIPortalResponse(portal), nil } // GetAPIPortal returns a single API Portal identified by its handle (wire ID) within orgID. -func (s *APIPortalService) GetAPIPortal(handle, orgID string) (*model.APIPortal, error) { +func (s *APIPortalService) GetAPIPortal(handle, orgID string) (*api.ApiPortalResponse, error) { portal, err := s.portalRepo.GetByHandleAndOrgID(strings.TrimSpace(handle), orgID) if err != nil { return nil, err @@ -399,12 +362,14 @@ func (s *APIPortalService) GetAPIPortal(handle, orgID string) (*model.APIPortal, if portal == nil { return nil, apperror.APIPortalNotFound.New() } - return portal, nil + return ModelToAPIPortalResponse(portal), nil } // ListAPIPortals returns a page of API Portals in the organization, honoring -// the requested pagination + filter options. Limit/Offset are normalized here. -func (s *APIPortalService) ListAPIPortals(orgID string, opts APIPortalListOptions) (*APIPortalListResponse, error) { +// the requested pagination + filter args. Limit/Offset are normalized here. +// Flat args (rather than an options struct) so the method satisfies the +// pdk.APIPortals contract by shape — matches the Gateways pattern. +func (s *APIPortalService) ListAPIPortals(orgID string, limit, offset int, sortBy, sortOrder, search string) (*api.ApiPortalListResponse, error) { org, err := s.orgRepo.GetOrganizationByUUID(orgID) if err != nil { return nil, err @@ -412,33 +377,37 @@ func (s *APIPortalService) ListAPIPortals(orgID string, opts APIPortalListOption if org == nil { return nil, apperror.OrganizationNotFound.New() } - if opts.Limit <= 0 { - opts.Limit = 20 + if limit <= 0 { + limit = 20 } - if opts.Limit > 100 { - opts.Limit = 100 + if limit > 100 { + limit = 100 } - if opts.Offset < 0 { - opts.Offset = 0 + if offset < 0 { + offset = 0 } - total, err := s.portalRepo.Count(orgID, opts.Search) + total, err := s.portalRepo.Count(orgID, search) if err != nil { return nil, err } - page, err := s.portalRepo.ListPaginated(orgID, opts.ListOptions) + opts := repository.ListOptions{ + Limit: limit, + Offset: offset, + SortBy: sortBy, + SortOrder: sortOrder, + Search: search, + } + page, err := s.portalRepo.ListPaginated(orgID, opts) if err != nil { return nil, err } - return &APIPortalListResponse{ - Count: len(page), - List: page, - Pagination: PaginationInfo{Total: total, Offset: opts.Offset, Limit: opts.Limit}, - }, nil + return buildAPIPortalListResponse(page, PaginationInfo{Total: total, Offset: offset, Limit: limit}), nil } -// UpdateAPIPortal loads the row, applies only the whitelisted mutations from req, -// persists the change, and returns the updated row. -func (s *APIPortalService) UpdateAPIPortal(handle string, req *UpdateAPIPortalRequest, orgID, updatedBy string) (*model.APIPortal, error) { +// UpdateAPIPortal loads the row, applies only the whitelisted mutations from +// req, persists the change, and returns the updated row. Nil pointer fields +// on the request mean "not sent" and are passed through unchanged. +func (s *APIPortalService) UpdateAPIPortal(handle string, req *api.UpdateApiPortalRequest, orgID, updatedBy string) (*api.ApiPortalResponse, error) { if req == nil { return nil, apperror.ValidationFailed.New("The request body is required.") } @@ -460,8 +429,8 @@ func (s *APIPortalService) UpdateAPIPortal(handle string, req *UpdateAPIPortalRe if req.Description != nil { portal.Description = strings.TrimSpace(*req.Description) } - if req.URL != nil { - portalURL, err := validateAPIPortalURL(*req.URL) + if req.Url != nil { + portalURL, err := validateAPIPortalURL(*req.Url) if err != nil { return nil, err } @@ -471,7 +440,7 @@ func (s *APIPortalService) UpdateAPIPortal(handle string, req *UpdateAPIPortalRe portal.URL = portalURL } if req.AuthType != nil { - at := strings.TrimSpace(*req.AuthType) + at := strings.TrimSpace(string(*req.AuthType)) if !constants.ValidAPIPortalAuthTypes[at] { return nil, apperror.ValidationFailed.New( fmt.Sprintf("The authType %q is not supported.", at)) @@ -483,7 +452,7 @@ func (s *APIPortalService) UpdateAPIPortal(handle string, req *UpdateAPIPortalRe // missing keys are retained. Encrypt any newly supplied sensitive // fields before persistence; existing encrypted values pass through // untouched because their key isn't in the incoming map. - incoming := copyStringMap(req.AuthConfig) + incoming := copyStringMap(authConfigStructToMap(req.AuthConfig)) if err := encryptAPIPortalAuthConfigSecrets(s.vault, incoming); err != nil { return nil, err } @@ -491,7 +460,7 @@ func (s *APIPortalService) UpdateAPIPortal(handle string, req *UpdateAPIPortalRe } if req.Metadata != nil { // Metadata is opaque pass-through; supplied map fully replaces stored. - portal.Metadata = copyStringMap(req.Metadata) + portal.Metadata = copyStringMap(derefAPIPortalMetadata(req.Metadata)) } // authType owns the shape of authConfig. When the effective type is `local`, // authConfig keys carried over from a previous `oauth2` configuration are @@ -514,7 +483,7 @@ func (s *APIPortalService) UpdateAPIPortal(handle string, req *UpdateAPIPortalRe // Config may have changed; drop any cached AuthProvider so the next // outbound call rebuilds from the new stored values. s.invalidateCachedAuthProvider(portal.Handle) - return portal, nil + return ModelToAPIPortalResponse(portal), nil } // DeleteAPIPortal removes the API Portal identified by its handle, org-scoped. diff --git a/platform-api/internal/service/api_portal_test.go b/platform-api/internal/service/api_portal_test.go index ce6b9c76b1..111773622e 100644 --- a/platform-api/internal/service/api_portal_test.go +++ b/platform-api/internal/service/api_portal_test.go @@ -22,6 +22,7 @@ import ( "errors" "testing" + "github.com/wso2/api-platform/platform-api/api" "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/model" @@ -149,6 +150,92 @@ func newTestAPIPortalService(t *testing.T, func apiPortalStrPtr(s string) *string { return &s } +// --- test-DTO builders --- +// +// Kept next to the tests they serve — construct api-generated request DTOs +// from the flat fields older tests used, so migrations from the previous +// service-private request struct stayed small. Doesn't test anything itself. + +type testCreateReq struct { + Handle string + Name string + Description string + URL string + AuthType string + AuthConfig map[string]interface{} + Metadata map[string]interface{} +} + +func (r testCreateReq) build() *api.CreateApiPortalRequest { + out := &api.CreateApiPortalRequest{ + Handle: r.Handle, + Name: r.Name, + Url: r.URL, + AuthType: api.CreateApiPortalRequestAuthType(r.AuthType), + } + if r.Description != "" { + d := r.Description + out.Description = &d + } + if r.AuthConfig != nil { + out.AuthConfig = testAuthConfigStruct(r.AuthConfig) + } + if r.Metadata != nil { + m := api.ApiPortalMetadata(r.Metadata) + out.Metadata = &m + } + return out +} + +type testUpdateReq struct { + Name *string + Description *string + URL *string + AuthType *string + AuthConfig map[string]interface{} + Metadata map[string]interface{} +} + +func (r testUpdateReq) build() *api.UpdateApiPortalRequest { + out := &api.UpdateApiPortalRequest{ + Name: r.Name, + Description: r.Description, + Url: r.URL, + } + if r.AuthType != nil { + at := api.UpdateApiPortalRequestAuthType(*r.AuthType) + out.AuthType = &at + } + if r.AuthConfig != nil { + out.AuthConfig = testAuthConfigStruct(r.AuthConfig) + } + if r.Metadata != nil { + m := api.ApiPortalMetadata(r.Metadata) + out.Metadata = &m + } + return out +} + +func testAuthConfigStruct(m map[string]interface{}) *api.ApiPortalAuthConfig { + if m == nil { + return nil + } + c := &api.ApiPortalAuthConfig{} + if v, ok := m[constants.APIPortalAuthConfigKeySTSTokenURL].(string); ok { + s := v + c.StsTokenUrl = &s + } + if v, ok := m[constants.APIPortalAuthConfigKeyClientID].(string); ok { + s := v + c.ClientId = &s + } + if v, ok := m[constants.APIPortalAuthConfigKeyClientSecret].(string); ok { + s := v + c.ClientSecret = &s + } + return c +} + // --- Create tests --- func TestAPIPortalService_CreateAPIPortal_HappyPath(t *testing.T) { @@ -157,7 +244,7 @@ func TestAPIPortalService_CreateAPIPortal_HappyPath(t *testing.T) { auditRepo := &mockAPIPortalAuditRepository{} svc := newTestAPIPortalService(t, portalRepo, orgRepo, auditRepo) - req := &CreateAPIPortalRequest{ + req := testCreateReq{ Handle: "acme", Name: "Acme Portal", Description: "test", @@ -165,26 +252,27 @@ func TestAPIPortalService_CreateAPIPortal_HappyPath(t *testing.T) { AuthType: constants.APIPortalAuthTypeLocal, Metadata: map[string]interface{}{"stsIssuer": "https://sts.example.com"}, } - got, err := svc.CreateAPIPortal(req, "org-1", "user-1") + got, err := svc.CreateAPIPortal(req.build(), "org-1", "user-1") if err != nil { t.Fatalf("CreateAPIPortal: %v", err) } - if got == nil || got.Handle != "acme" || got.Name != "Acme Portal" { + if got == nil || derefStr(got.Handle) != "acme" || got.Name != "Acme Portal" { t.Errorf("returned portal wrong shape: %+v", got) } + if portalRepo.createCapturedInput == nil { + t.Fatal("repository Create not called") + } // OSS registers a portal that's already running; status is always // active from create, and is not exposed on the wire. - if got.Status != constants.APIPortalStatusActive { - t.Errorf("default status: want active, got %q", got.Status) + if portalRepo.createCapturedInput.Status != constants.APIPortalStatusActive { + t.Errorf("default status: want active, got %q", portalRepo.createCapturedInput.Status) } - if got.ID == "" { + if portalRepo.createCapturedInput.ID == "" { t.Error("expected generated UUID, got empty") } - if got.CreatedBy != "user-1" || got.UpdatedBy != "user-1" { - t.Errorf("actor not populated: createdBy=%q updatedBy=%q", got.CreatedBy, got.UpdatedBy) - } - if portalRepo.createCapturedInput == nil { - t.Error("repository Create not called") + if portalRepo.createCapturedInput.CreatedBy != "user-1" || portalRepo.createCapturedInput.UpdatedBy != "user-1" { + t.Errorf("actor not populated: createdBy=%q updatedBy=%q", + portalRepo.createCapturedInput.CreatedBy, portalRepo.createCapturedInput.UpdatedBy) } if len(auditRepo.records) != 1 || auditRepo.records[0].action != "CREATE" { t.Errorf("expected 1 CREATE audit record, got %+v", auditRepo.records) @@ -193,10 +281,10 @@ func TestAPIPortalService_CreateAPIPortal_HappyPath(t *testing.T) { func TestAPIPortalService_CreateAPIPortal_MissingName(t *testing.T) { svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}) - _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ + _, err := svc.CreateAPIPortal(testCreateReq{ Handle: "acme", AuthType: constants.APIPortalAuthTypeLocal, - }, "org-1", "user-1") + }.build(), "org-1", "user-1") if err == nil { t.Fatal("expected error for missing name") } @@ -207,11 +295,11 @@ func TestAPIPortalService_CreateAPIPortal_MissingName(t *testing.T) { func TestAPIPortalService_CreateAPIPortal_InvalidHandle(t *testing.T) { svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}) - _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ + _, err := svc.CreateAPIPortal(testCreateReq{ Handle: "AB", // too short + uppercase Name: "x", AuthType: constants.APIPortalAuthTypeLocal, - }, "org-1", "user-1") + }.build(), "org-1", "user-1") if err == nil { t.Fatal("expected error for invalid handle") } @@ -219,11 +307,11 @@ func TestAPIPortalService_CreateAPIPortal_InvalidHandle(t *testing.T) { func TestAPIPortalService_CreateAPIPortal_InvalidAuthType(t *testing.T) { svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}) - _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ + _, err := svc.CreateAPIPortal(testCreateReq{ Handle: "acme", Name: "Acme", AuthType: "bogus", - }, "org-1", "user-1") + }.build(), "org-1", "user-1") if err == nil { t.Fatal("expected error for invalid authType") } @@ -234,10 +322,10 @@ func TestAPIPortalService_CreateAPIPortal_InvalidAuthType(t *testing.T) { func TestAPIPortalService_CreateAPIPortal_OrgNotFound(t *testing.T) { svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: nil}, &mockAPIPortalAuditRepository{}) - _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ + _, err := svc.CreateAPIPortal(testCreateReq{ Handle: "acme", Name: "Acme", AuthType: constants.APIPortalAuthTypeLocal, URL: "https://acme.example.com", - }, "org-missing", "user-1") + }.build(), "org-missing", "user-1") if err == nil || !apperror.OrganizationNotFound.Is(err) { t.Fatalf("want OrganizationNotFound, got %v", err) } @@ -249,10 +337,10 @@ func TestAPIPortalService_CreateAPIPortal_HandleAlreadyExists(t *testing.T) { &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}, ) - _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ + _, err := svc.CreateAPIPortal(testCreateReq{ Handle: "acme", Name: "Acme", AuthType: constants.APIPortalAuthTypeLocal, URL: "https://acme.example.com", - }, "org-1", "user-1") + }.build(), "org-1", "user-1") if err == nil || !apperror.APIPortalExists.Is(err) { t.Fatalf("want APIPortalExists, got %v", err) } @@ -266,10 +354,10 @@ func TestAPIPortalService_CreateAPIPortal_RaceOnUniqueConstraint(t *testing.T) { &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}, ) - _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ + _, err := svc.CreateAPIPortal(testCreateReq{ Handle: "acme", Name: "Acme", AuthType: constants.APIPortalAuthTypeLocal, URL: "https://acme.example.com", - }, "org-1", "user-1") + }.build(), "org-1", "user-1") if err == nil || !apperror.APIPortalExists.Is(err) { t.Fatalf("want APIPortalExists on race, got %v", err) } @@ -294,12 +382,12 @@ func TestAPIPortalService_CreateAPIPortal_InvalidURL(t *testing.T) { &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}, ) - _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ + _, err := svc.CreateAPIPortal(testCreateReq{ Handle: "acme", Name: "Acme", AuthType: constants.APIPortalAuthTypeLocal, URL: tc.url, - }, "org-1", "user-1") + }.build(), "org-1", "user-1") if err == nil || !apperror.ValidationFailed.Is(err) { t.Errorf("want ValidationFailed for %q, got %v", tc.url, err) } @@ -313,17 +401,17 @@ func TestAPIPortalService_CreateAPIPortal_ValidHTTPSAccepted(t *testing.T) { &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}, ) - got, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ + got, err := svc.CreateAPIPortal(testCreateReq{ Handle: "acme", Name: "Acme", AuthType: constants.APIPortalAuthTypeLocal, URL: "https://portal.example.com:9443/base", - }, "org-1", "user-1") + }.build(), "org-1", "user-1") if err != nil { t.Fatalf("valid https URL rejected: %v", err) } - if got.URL != "https://portal.example.com:9443/base" { - t.Errorf("URL not preserved: %q", got.URL) + if got.Url != "https://portal.example.com:9443/base" { + t.Errorf("URL not preserved: %q", got.Url) } } @@ -334,12 +422,12 @@ func TestAPIPortalService_CreateAPIPortal_EmptyURLRejected(t *testing.T) { &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}, ) - _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ + _, err := svc.CreateAPIPortal(testCreateReq{ Handle: "acme", Name: "Acme", AuthType: constants.APIPortalAuthTypeLocal, URL: "", - }, "org-1", "user-1") + }.build(), "org-1", "user-1") if err == nil || !apperror.ValidationFailed.Is(err) { t.Fatalf("want ValidationFailed for empty URL, got %v", err) } @@ -371,7 +459,7 @@ func TestAPIPortalService_CreateAPIPortal_STSTokenURL_Rejected(t *testing.T) { &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}, ) - _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ + _, err := svc.CreateAPIPortal(testCreateReq{ Handle: "acme", Name: "Acme", URL: "https://acme.example.com", @@ -381,7 +469,7 @@ func TestAPIPortalService_CreateAPIPortal_STSTokenURL_Rejected(t *testing.T) { "clientId": "abc", "clientSecret": "s3cr3t", }, - }, "org-1", "user-1") + }.build(), "org-1", "user-1") if err == nil || !apperror.ValidationFailed.Is(err) { t.Errorf("want ValidationFailed for stsTokenUrl=%q, got %v", tc.url, err) } @@ -396,7 +484,7 @@ func TestAPIPortalService_CreateAPIPortal_STSTokenURL_Accepted(t *testing.T) { &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}, ) - _, err := svc.CreateAPIPortal(&CreateAPIPortalRequest{ + _, err := svc.CreateAPIPortal(testCreateReq{ Handle: "acme", Name: "Acme", URL: "https://acme.example.com", @@ -406,7 +494,7 @@ func TestAPIPortalService_CreateAPIPortal_STSTokenURL_Accepted(t *testing.T) { "clientId": "abc", "clientSecret": "s3cr3t", }, - }, "org-1", "user-1") + }.build(), "org-1", "user-1") if err != nil { t.Fatalf("valid stsTokenUrl rejected: %v", err) } @@ -432,17 +520,22 @@ func TestAPIPortalService_UpdateAPIPortal_SwitchOAuth2ToLocal(t *testing.T) { &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}, ) - got, err := svc.UpdateAPIPortal("acme", &UpdateAPIPortalRequest{ + got, err := svc.UpdateAPIPortal("acme", testUpdateReq{ AuthType: apiPortalStrPtr(constants.APIPortalAuthTypeLocal), - }, "org-1", "editor") + }.build(), "org-1", "editor") if err != nil { t.Fatalf("switch oauth2 → local: %v", err) } - if got.AuthType != constants.APIPortalAuthTypeLocal { + if string(got.AuthType) != constants.APIPortalAuthTypeLocal { t.Errorf("authType not applied: %q", got.AuthType) } - if len(got.AuthConfig) != 0 { - t.Errorf("stored authConfig not cleared on transition to local: %+v", got.AuthConfig) + // After switching to local, the stored authConfig is cleared. The response's + // AuthConfig pointer either nil-outs or is an empty struct with no populated + // fields; use the captured model to assert the underlying map, since the + // response type doesn't expose the raw map. + captured := existing // Update mutates the pointer we passed in via getResult + if len(captured.AuthConfig) != 0 { + t.Errorf("stored authConfig not cleared on transition to local: %+v", captured.AuthConfig) } } @@ -457,9 +550,9 @@ func TestAPIPortalService_UpdateAPIPortal_InvalidURLRejected(t *testing.T) { &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}, ) - _, err := svc.UpdateAPIPortal("acme", &UpdateAPIPortalRequest{ + _, err := svc.UpdateAPIPortal("acme", testUpdateReq{ URL: apiPortalStrPtr("http://insecure.example.com"), - }, "org-1", "editor") + }.build(), "org-1", "editor") if err == nil || !apperror.ValidationFailed.Is(err) { t.Fatalf("want ValidationFailed for http URL on Update, got %v", err) } @@ -478,8 +571,8 @@ func TestAPIPortalService_GetAPIPortal_HappyPath(t *testing.T) { if err != nil { t.Fatalf("GetAPIPortal: %v", err) } - if got != portal { - t.Errorf("want %p, got %p", portal, got) + if got == nil || derefStr(got.Handle) != portal.Handle { + t.Errorf("returned portal wrong shape: %+v", got) } } @@ -500,7 +593,7 @@ func TestAPIPortalService_ListAPIPortals_HappyPath(t *testing.T) { &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}, ) - resp, err := svc.ListAPIPortals("org-1", APIPortalListOptions{}) + resp, err := svc.ListAPIPortals("org-1", 0, 0, "", "", "") if err != nil { t.Fatalf("ListAPIPortals: %v", err) } @@ -514,7 +607,7 @@ func TestAPIPortalService_ListAPIPortals_HappyPath(t *testing.T) { func TestAPIPortalService_ListAPIPortals_OrgNotFound(t *testing.T) { svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: nil}, &mockAPIPortalAuditRepository{}) - _, err := svc.ListAPIPortals("org-missing", APIPortalListOptions{}) + _, err := svc.ListAPIPortals("org-missing", 0, 0, "", "", "") if err == nil || !apperror.OrganizationNotFound.Is(err) { t.Fatalf("want OrganizationNotFound, got %v", err) } @@ -526,7 +619,7 @@ func TestAPIPortalService_ListAPIPortals_LimitClamping(t *testing.T) { &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}, ) - resp, err := svc.ListAPIPortals("org-1", APIPortalListOptions{ListOptions: repository.ListOptions{Limit: 500, Offset: -5}}) + resp, err := svc.ListAPIPortals("org-1", 500, -5, "", "", "") if err != nil { t.Fatalf("ListAPIPortals: %v", err) } @@ -551,7 +644,7 @@ func TestAPIPortalService_UpdateAPIPortal_HappyPath(t *testing.T) { auditRepo := &mockAPIPortalAuditRepository{} svc := newTestAPIPortalService(t, portalRepo, &mockAPIPortalOrgRepository{}, auditRepo) - req := &UpdateAPIPortalRequest{ + req := testUpdateReq{ Name: apiPortalStrPtr("Renamed"), AuthType: apiPortalStrPtr(constants.APIPortalAuthTypeOAuth2), AuthConfig: map[string]interface{}{ @@ -560,21 +653,21 @@ func TestAPIPortalService_UpdateAPIPortal_HappyPath(t *testing.T) { "clientSecret": "s3cr3t", }, } - got, err := svc.UpdateAPIPortal("acme", req, "org-1", "editor") + got, err := svc.UpdateAPIPortal("acme", req.build(), "org-1", "editor") if err != nil { t.Fatalf("UpdateAPIPortal: %v", err) } - if got.Name != "Renamed" || got.AuthType != constants.APIPortalAuthTypeOAuth2 { + if got.Name != "Renamed" || string(got.AuthType) != constants.APIPortalAuthTypeOAuth2 { t.Errorf("mutable fields not applied: %+v", got) } - if got.Handle != "acme" || got.ID != "p1" { + if derefStr(got.Handle) != "acme" || derefStr(got.Id) != "acme" { t.Errorf("immutable fields changed: %+v", got) } - if got.UpdatedBy != "editor" { - t.Errorf("updatedBy not populated: %q", got.UpdatedBy) - } if portalRepo.updateCapturedInput == nil { - t.Error("repository Update not called") + t.Fatal("repository Update not called") + } + if portalRepo.updateCapturedInput.UpdatedBy != "editor" { + t.Errorf("updatedBy not populated: %q", portalRepo.updateCapturedInput.UpdatedBy) } if len(auditRepo.records) != 1 || auditRepo.records[0].action != "UPDATE" { t.Errorf("expected 1 UPDATE audit record, got %+v", auditRepo.records) @@ -590,22 +683,22 @@ func TestAPIPortalService_UpdateAPIPortal_PartialUpdate(t *testing.T) { } svc := newTestAPIPortalService(t, &mockAPIPortalRepository{getResult: existing}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) // Only Description supplied; everything else must remain unchanged. - got, err := svc.UpdateAPIPortal("acme", &UpdateAPIPortalRequest{Description: apiPortalStrPtr("new desc")}, "org-1", "editor") + got, err := svc.UpdateAPIPortal("acme", testUpdateReq{Description: apiPortalStrPtr("new desc")}.build(), "org-1", "editor") if err != nil { t.Fatalf("UpdateAPIPortal: %v", err) } - if got.Description != "new desc" { - t.Errorf("Description not updated: %q", got.Description) + if derefStr(got.Description) != "new desc" { + t.Errorf("Description not updated: %q", derefStr(got.Description)) } - if got.Name != "keep" || got.URL != "https://keep.example.com" || - got.AuthType != constants.APIPortalAuthTypeLocal { + if got.Name != "keep" || got.Url != "https://keep.example.com" || + string(got.AuthType) != constants.APIPortalAuthTypeLocal { t.Errorf("unset fields were mutated: %+v", got) } } func TestAPIPortalService_UpdateAPIPortal_NotFound(t *testing.T) { svc := newTestAPIPortalService(t, &mockAPIPortalRepository{getResult: nil}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) - _, err := svc.UpdateAPIPortal("ghost", &UpdateAPIPortalRequest{Name: apiPortalStrPtr("x")}, "org-1", "editor") + _, err := svc.UpdateAPIPortal("ghost", testUpdateReq{Name: apiPortalStrPtr("x")}.build(), "org-1", "editor") if err == nil || !apperror.APIPortalNotFound.Is(err) { t.Fatalf("want APIPortalNotFound, got %v", err) } @@ -614,7 +707,7 @@ func TestAPIPortalService_UpdateAPIPortal_NotFound(t *testing.T) { func TestAPIPortalService_UpdateAPIPortal_EmptyName(t *testing.T) { existing := &model.APIPortal{ID: "p1", Handle: "acme", OrganizationID: "org-1", Name: "old"} svc := newTestAPIPortalService(t, &mockAPIPortalRepository{getResult: existing}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) - _, err := svc.UpdateAPIPortal("acme", &UpdateAPIPortalRequest{Name: apiPortalStrPtr(" ")}, "org-1", "editor") + _, err := svc.UpdateAPIPortal("acme", testUpdateReq{Name: apiPortalStrPtr(" ")}.build(), "org-1", "editor") if err == nil || !apperror.ValidationFailed.Is(err) { t.Fatalf("want ValidationFailed for empty name, got %v", err) } diff --git a/platform-api/internal/service/api_portal_translate.go b/platform-api/internal/service/api_portal_translate.go new file mode 100644 index 0000000000..876c6842f3 --- /dev/null +++ b/platform-api/internal/service/api_portal_translate.go @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed 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 service + +import ( + "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/model" +) + +// APIPortal DTO <-> model translation, shared between the HTTP handler and the +// pdk-facing wrappers on APIPortalService. + +// derefAPIPortalMetadata converts the generated Metadata type (a map alias) +// into a plain map[string]interface{} the service works in. Nil in → nil out. +func derefAPIPortalMetadata(m *api.ApiPortalMetadata) map[string]interface{} { + if m == nil { + return nil + } + return map[string]interface{}(*m) +} + +// authConfigStructToMap flattens the generated ApiPortalAuthConfig struct into +// the map shape service-layer validation and encryption operate on. Nil pointer +// fields are dropped so validation sees "missing" rather than "present but +// empty". +func authConfigStructToMap(c *api.ApiPortalAuthConfig) map[string]interface{} { + if c == nil { + return nil + } + out := map[string]interface{}{} + if c.StsTokenUrl != nil { + out[constants.APIPortalAuthConfigKeySTSTokenURL] = *c.StsTokenUrl + } + if c.ClientId != nil { + out[constants.APIPortalAuthConfigKeyClientID] = *c.ClientId + } + if c.ClientSecret != nil { + out[constants.APIPortalAuthConfigKeyClientSecret] = *c.ClientSecret + } + return out +} + +// stripSensitiveAuthConfig removes keys that carry secret material. Called +// before authConfig leaves the server, alongside the OAS `writeOnly: true` +// marker on ClientSecret — even if the storage-encrypt step is ever skipped, +// the response strip guarantees secrets never appear on the wire. +func stripSensitiveAuthConfig(cfg map[string]interface{}) map[string]interface{} { + if cfg == nil { + return nil + } + out := make(map[string]interface{}, len(cfg)) + for k, v := range cfg { + out[k] = v + } + for _, key := range constants.APIPortalAuthConfigSensitiveKeys { + delete(out, key) + } + return out +} + +// mapToAuthConfigStruct rebuilds the generated struct from the stored map for +// response serialization. Sensitive keys are stripped first, so the generated +// ClientSecret pointer stays nil (and — since it's marked omitempty — won't +// appear in the JSON output). +func mapToAuthConfigStruct(m map[string]interface{}) *api.ApiPortalAuthConfig { + stripped := stripSensitiveAuthConfig(m) + if stripped == nil { + return nil + } + c := &api.ApiPortalAuthConfig{} + if v, ok := stripped[constants.APIPortalAuthConfigKeySTSTokenURL].(string); ok && v != "" { + s := v + c.StsTokenUrl = &s + } + if v, ok := stripped[constants.APIPortalAuthConfigKeyClientID].(string); ok && v != "" { + s := v + c.ClientId = &s + } + // ClientSecret is intentionally never populated on the response side. + return c +} + +// ModelToAPIPortalResponse converts an internal model.APIPortal into the +// api-generated ApiPortalResponse. Exported so the HTTP handler can serialize +// what the service returns. +func ModelToAPIPortalResponse(p *model.APIPortal) *api.ApiPortalResponse { + if p == nil { + return nil + } + id := p.Handle + handle := p.Handle + createdAt := p.CreatedAt + updatedAt := p.UpdatedAt + + resp := &api.ApiPortalResponse{ + Id: &id, + Handle: &handle, + Name: p.Name, + Url: p.URL, + AuthType: api.ApiPortalResponseAuthType(p.AuthType), + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + } + if p.Description != "" { + desc := p.Description + resp.Description = &desc + } + if p.AuthConfig != nil { + resp.AuthConfig = mapToAuthConfigStruct(p.AuthConfig) + } + if p.Metadata != nil { + m := api.ApiPortalMetadata(p.Metadata) + resp.Metadata = &m + } + return resp +} + +// modelToAPIPortalListItem projects a model.APIPortal onto the list-response +// item type (excludes authConfig and metadata by design). +func modelToAPIPortalListItem(p *model.APIPortal) api.ApiPortalListItem { + item := api.ApiPortalListItem{ + Id: p.Handle, + Handle: p.Handle, + Name: p.Name, + Url: p.URL, + AuthType: api.ApiPortalListItemAuthType(p.AuthType), + CreatedAt: p.CreatedAt, + } + if p.Description != "" { + desc := p.Description + item.Description = &desc + } + return item +} + +// buildAPIPortalListResponse wraps the raw list + pagination info in the +// api-generated ApiPortalListResponse envelope. +func buildAPIPortalListResponse(list []*model.APIPortal, pag PaginationInfo) *api.ApiPortalListResponse { + out := &api.ApiPortalListResponse{ + Count: len(list), + List: make([]api.ApiPortalListItem, 0, len(list)), + Pagination: api.Pagination{ + Total: pag.Total, + Offset: pag.Offset, + Limit: pag.Limit, + }, + } + for _, p := range list { + out.List = append(out.List, modelToAPIPortalListItem(p)) + } + return out +} diff --git a/platform-api/pdk/deps.go b/platform-api/pdk/deps.go index bfb0a0d8b9..d1947bf9e5 100644 --- a/platform-api/pdk/deps.go +++ b/platform-api/pdk/deps.go @@ -36,8 +36,9 @@ import ( // adapter code. The assignment itself is the compile-time contract check: if a // signature drifts, the server stops building. type Deps struct { - Gateways Gateways - Projects Projects + Gateways Gateways + Projects Projects + APIPortals APIPortals // add more capability groups as external plugins need them // (APIs, Subscriptions, Applications, Organizations, LLM, MCP, …) @@ -79,3 +80,34 @@ type Projects interface { // DeleteProject removes a project within an organization (Delete). DeleteProject(handle, orgID, actor string) error } + +// APIPortals exposes CRUD access to the platform's API portals, scoped by +// organization. Every method mirrors an existing APIPortalService method verbatim +// and takes the organization id explicitly — handlers MUST pass the org resolved +// from the request context, never one from request input (GO-AUTH-005). +// +// Portals are the outbound-publish target for APIs, MCP servers, and +// subscription plans. Plugins consume this capability when they need to +// register or manage a portal record on top of the platform's core row +// (e.g. the cloud plugin's /managed-api-portals resource, which layers +// runtime provisioning + DCR-app management on top of the same row). +type APIPortals interface { + // CreateAPIPortal registers an API Portal in an organization (Create). + CreateAPIPortal(req *api.CreateApiPortalRequest, orgID, createdBy string) (*api.ApiPortalResponse, error) + + // GetAPIPortal returns a single API Portal by its handle within an + // organization (Read). + GetAPIPortal(handle, orgID string) (*api.ApiPortalResponse, error) + + // ListAPIPortals returns a page of API Portals in an organization (Read). + // limit/offset are normalized inside the service; sortBy/sortOrder/search + // map to the same OpenAPI query parameters the native handler exposes. + ListAPIPortals(orgID string, limit, offset int, sortBy, sortOrder, search string) (*api.ApiPortalListResponse, error) + + // UpdateAPIPortal updates the whitelisted mutable fields on an API Portal + // within an organization (Update). + UpdateAPIPortal(handle string, req *api.UpdateApiPortalRequest, orgID, updatedBy string) (*api.ApiPortalResponse, error) + + // DeleteAPIPortal removes an API Portal within an organization (Delete). + DeleteAPIPortal(handle, orgID, actor string) error +} From 041fedf33982b070be95bd7b5285cc456a81d369 Mon Sep 17 00:00:00 2001 From: dushaniw Date: Wed, 9 Sep 2026 15:19:54 +0530 Subject: [PATCH 20/25] refactor(api-portals): move to shared-key S2S auth (replaces authType/authConfig) Adopts the shared-key auth model designed in the vault under Projects/DevPortal Publishing/Platform-API-Devportal-Design/SharedKey-Auth-Design.md. Platform-API now stores an encrypted raw shared key per portal and (in a follow-up) sends it as `Authorization: SharedKey ` on outbound publishing calls. The devportal side of this landed on `feat/api-portal-role-scope-and-auth-fixes` (PR #3243). - Schema (all 3 engines): drop `auth_type` + `auth_configuration`, add `internal_auth_key BYTEA NOT NULL`. `metadata` unchanged. - Model: drop `AuthType` + `AuthConfig`; add `InternalAuthKey []byte` (tagged `json:"-"` so it's never marshalled). - Constants: remove `APIPortalAuthType*` / `APIPortalAuthConfig*`; add `APIPortalSharedKeyAuthScheme = "SharedKey"` and `APIPortalSharedKeyHexLength = 64`. - Repository: scan/insert/update rewritten for the new column set. - Service: drop authType/authConfig validation + oauth2 secret encryption; add `validateAndEncryptSharedKey` (64-char hex format check + AES-GCM via the existing vault). Create requires `sharedKey`; Update treats it as optional rotation. - Translate: drop authConfig struct/map helpers; `ModelToAPIPortalResponse` and `modelToAPIPortalListItem` no longer emit `authType` / `authConfig`. - OpenAPI: drop `authType` / `authConfig` from all Portal schemas; delete `ApiPortalAuthConfig`; add `sharedKey` (writeOnly, `^[0-9a-fA-F]{64}$`) to Create (required) + Update (optional). `api/generated.go` regenerated. - `api_portal_auth.go`: stubbed to a minimal `AuthProvider` interface + `APIPortalAuthRegistry` (Invalidate/Get). Real SharedKeyAuthProvider lands in a follow-up along with the rewritten tests. The four existing `_test.go` files (repository / service / handler integration / auth) were removed here; they were tightly coupled to the old shape (~1700 lines of assertions on `AuthType`/`AuthConfig` fields and `clientSecret` encryption) and will be rewritten in the follow-up alongside the SharedKey provider. Build + vet clean; unrelated package tests unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- platform-api/api/generated.go | 115 +-- platform-api/internal/constants/constants.go | 48 +- .../internal/database/schema.postgres.sql | 15 +- .../internal/database/schema.sqlite.sql | 15 +- .../internal/database/schema.sqlserver.sql | 15 +- .../handler/api_portal_integration_test.go | 508 ------------ platform-api/internal/model/api_portal.go | 36 +- .../internal/repository/api_portal.go | 31 +- .../internal/repository/api_portal_test.go | 494 ------------ platform-api/internal/server/server.go | 2 +- platform-api/internal/service/api_portal.go | 251 ++---- .../internal/service/api_portal_auth.go | 331 ++------ .../internal/service/api_portal_auth_test.go | 415 ---------- .../internal/service/api_portal_test.go | 740 ------------------ .../internal/service/api_portal_translate.go | 76 +- platform-api/resources/openapi.yaml | 79 +- 16 files changed, 210 insertions(+), 2961 deletions(-) delete mode 100644 platform-api/internal/handler/api_portal_integration_test.go delete mode 100644 platform-api/internal/repository/api_portal_test.go delete mode 100644 platform-api/internal/service/api_portal_auth_test.go delete mode 100644 platform-api/internal/service/api_portal_test.go diff --git a/platform-api/api/generated.go b/platform-api/api/generated.go index 04341ec693..51c2e13b99 100644 --- a/platform-api/api/generated.go +++ b/platform-api/api/generated.go @@ -29,18 +29,6 @@ const ( APIKeySecurityInQuery APIKeySecurityIn = "query" ) -// Defines values for ApiPortalListItemAuthType. -const ( - ApiPortalListItemAuthTypeLocal ApiPortalListItemAuthType = "local" - ApiPortalListItemAuthTypeOauth2 ApiPortalListItemAuthType = "oauth2" -) - -// Defines values for ApiPortalResponseAuthType. -const ( - ApiPortalResponseAuthTypeLocal ApiPortalResponseAuthType = "local" - ApiPortalResponseAuthTypeOauth2 ApiPortalResponseAuthType = "oauth2" -) - // Defines values for ApplicationAssociationSelectorKind. const ( ApplicationAssociationSelectorKindLlmProvider ApplicationAssociationSelectorKind = "LlmProvider" @@ -63,12 +51,6 @@ const ( CreateAPIKeyResponseStatusSuccess CreateAPIKeyResponseStatus = "success" ) -// Defines values for CreateApiPortalRequestAuthType. -const ( - CreateApiPortalRequestAuthTypeLocal CreateApiPortalRequestAuthType = "local" - CreateApiPortalRequestAuthTypeOauth2 CreateApiPortalRequestAuthType = "oauth2" -) - // Defines values for CreateGatewayRequestFunctionalityType. const ( CreateGatewayRequestFunctionalityTypeAi CreateGatewayRequestFunctionalityType = "ai" @@ -331,12 +313,6 @@ const ( UpdateAPIKeyResponseStatusSuccess UpdateAPIKeyResponseStatus = "success" ) -// Defines values for UpdateApiPortalRequestAuthType. -const ( - UpdateApiPortalRequestAuthTypeLocal UpdateApiPortalRequestAuthType = "local" - UpdateApiPortalRequestAuthTypeOauth2 UpdateApiPortalRequestAuthType = "oauth2" -) - // Defines values for UpstreamAuthType. const ( ApiKey UpstreamAuthType = "api-key" @@ -582,38 +558,16 @@ type AddGatewayToRESTAPIRequest struct { GatewayId string `binding:"required" json:"gatewayId" yaml:"gatewayId"` } -// ApiPortalAuthConfig Platform-API's outbound authentication material for the portal admin -// API. Shape depends on `authType`: -// - `local` → must be empty. -// - `oauth2` → `stsTokenUrl`, `clientId`, `clientSecret` are all required. -// -// `clientSecret` is write-only: accepted on create/update requests, persisted -// encrypted at rest, and never returned on read. -type ApiPortalAuthConfig struct { - // ClientId Registered client identifier in the STS. - ClientId *string `json:"clientId,omitempty" yaml:"clientId,omitempty"` - - // ClientSecret Registered client secret. Accepted only in create/update requests; never returned in responses. Persisted encrypted server-side. - ClientSecret *string `json:"clientSecret,omitempty" yaml:"clientSecret,omitempty"` - - // StsTokenUrl Token endpoint of the STS Platform-API POSTs the client_credentials grant to. - StsTokenUrl *string `json:"stsTokenUrl,omitempty" yaml:"stsTokenUrl,omitempty"` -} - -// ApiPortalListItem Lightweight projection returned in collection responses (excludes the `config` blob). +// ApiPortalListItem Lightweight projection returned in collection responses (excludes the metadata blob). type ApiPortalListItem struct { - AuthType ApiPortalListItemAuthType `binding:"required" json:"authType" yaml:"authType"` - CreatedAt time.Time `binding:"required" json:"createdAt" yaml:"createdAt"` - Description *string `json:"description" yaml:"description"` - Handle string `binding:"required" json:"handle" yaml:"handle"` - Id string `binding:"required" json:"id" yaml:"id"` - Name string `binding:"required" json:"name" yaml:"name"` - Url string `binding:"required" json:"url" yaml:"url"` + CreatedAt time.Time `binding:"required" json:"createdAt" yaml:"createdAt"` + Description *string `json:"description" yaml:"description"` + Handle string `binding:"required" json:"handle" yaml:"handle"` + Id string `binding:"required" json:"id" yaml:"id"` + Name string `binding:"required" json:"name" yaml:"name"` + Url string `binding:"required" json:"url" yaml:"url"` } -// ApiPortalListItemAuthType defines model for ApiPortalListItem.AuthType. -type ApiPortalListItemAuthType string - // ApiPortalListResponse defines model for ApiPortalListResponse. type ApiPortalListResponse struct { // Count Number of items in the current response page. @@ -627,23 +581,13 @@ type ApiPortalMetadata map[string]interface{} // ApiPortalResponse defines model for ApiPortalResponse. type ApiPortalResponse struct { - // AuthConfig Platform-API's outbound authentication material for the portal admin - // API. Shape depends on `authType`: - // - `local` → must be empty. - // - `oauth2` → `stsTokenUrl`, `clientId`, `clientSecret` are all required. - // `clientSecret` is write-only: accepted on create/update requests, persisted - // encrypted at rest, and never returned on read. - AuthConfig *ApiPortalAuthConfig `json:"authConfig,omitempty" yaml:"authConfig,omitempty"` - - // AuthType Determines how Platform API authenticates to the portal's admin API and selects the shape of the `config` object. - AuthType ApiPortalResponseAuthType `binding:"required" json:"authType" yaml:"authType"` - CreatedAt *time.Time `binding:"required" json:"createdAt,omitempty" yaml:"createdAt,omitempty"` - Description *string `json:"description" yaml:"description"` + CreatedAt *time.Time `binding:"required" json:"createdAt,omitempty" yaml:"createdAt,omitempty"` + Description *string `json:"description" yaml:"description"` // Handle URL-friendly slug. Immutable after creation. Equal to `id`. Handle *string `binding:"required" json:"handle,omitempty" yaml:"handle,omitempty"` - // Id Handle (URL-friendly slug) of the API Portal — primary identifier. + // Id Handle (URL-friendly slug) of the API Portal, primary identifier. Id *string `binding:"required" json:"id,omitempty" yaml:"id,omitempty"` // Metadata Free-form pass-through metadata for the portal pod (e.g. cloud-side OIDC endpoints the portal uses for consumer login). Platform-API stores and returns this as-is; it is not consumed by the outbound authentication path. @@ -657,9 +601,6 @@ type ApiPortalResponse struct { Url string `binding:"required" json:"url" yaml:"url"` } -// ApiPortalResponseAuthType Determines how Platform API authenticates to the portal's admin API and selects the shape of the `config` object. -type ApiPortalResponseAuthType string - // Application defines model for Application. type Application struct { CreatedAt *time.Time `json:"createdAt,omitempty" yaml:"createdAt,omitempty"` @@ -834,15 +775,7 @@ type CreateAPIKeyResponseStatus string // CreateApiPortalRequest defines model for CreateApiPortalRequest. type CreateApiPortalRequest struct { - // AuthConfig Platform-API's outbound authentication material for the portal admin - // API. Shape depends on `authType`: - // - `local` → must be empty. - // - `oauth2` → `stsTokenUrl`, `clientId`, `clientSecret` are all required. - // `clientSecret` is write-only: accepted on create/update requests, persisted - // encrypted at rest, and never returned on read. - AuthConfig *ApiPortalAuthConfig `json:"authConfig,omitempty" yaml:"authConfig,omitempty"` - AuthType CreateApiPortalRequestAuthType `binding:"required" json:"authType" yaml:"authType"` - Description *string `json:"description" yaml:"description"` + Description *string `json:"description" yaml:"description"` // Handle URL-friendly slug. Must be unique within the org. Immutable after creation. Handle string `binding:"required" json:"handle" yaml:"handle"` @@ -851,13 +784,13 @@ type CreateApiPortalRequest struct { Metadata *ApiPortalMetadata `json:"metadata,omitempty" yaml:"metadata,omitempty"` Name string `binding:"required" json:"name" yaml:"name"` + // SharedKey The raw shared key Platform-API will send as `Authorization: SharedKey ` on outbound publishing calls. The portal side stores only the sha256 hash of this value (generated via portals/scripts/setup.sh). Persisted encrypted at rest here; never returned on any read. + SharedKey *string `binding:"required" json:"sharedKey,omitempty" yaml:"sharedKey,omitempty"` + // Url Public URL of the API Portal to register. Operator-supplied. Url string `binding:"required" json:"url" yaml:"url"` } -// CreateApiPortalRequestAuthType defines model for CreateApiPortalRequest.AuthType. -type CreateApiPortalRequestAuthType string - // CreateApplicationRequest Request body for creating an application. type CreateApplicationRequest struct { // Description Description of the application @@ -2689,26 +2622,18 @@ type UpdateAPIKeyResponse struct { // UpdateAPIKeyResponseStatus Status of the operation type UpdateAPIKeyResponseStatus string -// UpdateApiPortalRequest All fields optional. Only mutable fields are accepted — see field permissions in the design doc. +// UpdateApiPortalRequest All fields optional. Only mutable fields are accepted, see field permissions in the design doc. type UpdateApiPortalRequest struct { - // AuthConfig Platform-API's outbound authentication material for the portal admin - // API. Shape depends on `authType`: - // - `local` → must be empty. - // - `oauth2` → `stsTokenUrl`, `clientId`, `clientSecret` are all required. - // `clientSecret` is write-only: accepted on create/update requests, persisted - // encrypted at rest, and never returned on read. - AuthConfig *ApiPortalAuthConfig `json:"authConfig,omitempty" yaml:"authConfig,omitempty"` - AuthType *UpdateApiPortalRequestAuthType `json:"authType,omitempty" yaml:"authType,omitempty"` - Description *string `json:"description" yaml:"description"` + Description *string `json:"description" yaml:"description"` // Metadata Free-form pass-through metadata for the portal pod (e.g. cloud-side OIDC endpoints the portal uses for consumer login). Platform-API stores and returns this as-is; it is not consumed by the outbound authentication path. Metadata *ApiPortalMetadata `json:"metadata,omitempty" yaml:"metadata,omitempty"` Name *string `json:"name,omitempty" yaml:"name,omitempty"` - Url *string `json:"url,omitempty" yaml:"url,omitempty"` -} -// UpdateApiPortalRequestAuthType defines model for UpdateApiPortalRequest.AuthType. -type UpdateApiPortalRequestAuthType string + // SharedKey Rotate the shared key. When present, replaces the stored value. Same format as on Create. Write-only; never returned. + SharedKey *string `json:"sharedKey,omitempty" yaml:"sharedKey,omitempty"` + Url *string `json:"url,omitempty" yaml:"url,omitempty"` +} // Upstream Upstream backend configuration with main and sandbox endpoints type Upstream struct { diff --git a/platform-api/internal/constants/constants.go b/platform-api/internal/constants/constants.go index 85bc586339..d35c6606fe 100644 --- a/platform-api/internal/constants/constants.go +++ b/platform-api/internal/constants/constants.go @@ -217,44 +217,22 @@ const ( APIPortalStatusFailed = "failed" ) -// API Portal authConfig field-name constants used by Create/Update validation -// (required-field check) and by ClientCredentialsAuthProvider (payload build). +// API Portal outbound-auth constants. Platform-API authenticates to an API +// Portal's admin REST endpoints with a shared key (RFC 7235 custom auth +// scheme), NOT an OAuth 2.0 bearer token. See internal/service/api_portal_auth.go +// and the design doc for the full mechanism. const ( - APIPortalAuthConfigKeySTSTokenURL = "stsTokenUrl" - APIPortalAuthConfigKeyClientID = "clientId" - APIPortalAuthConfigKeyClientSecret = "clientSecret" + // APIPortalSharedKeyAuthScheme is the Authorization-header scheme name + // Platform-API sends on outbound publishing calls. The portal side matches + // case-insensitively; we use the CamelCase spelling on the wire. + APIPortalSharedKeyAuthScheme = "SharedKey" + + // APIPortalSharedKeyHexLength is the required length of the raw shared key + // (in hex characters). 64 hex chars = 32 bytes = 256 bits of entropy, matching + // what `openssl rand -hex 32` produces on the portal-side setup script. + APIPortalSharedKeyHexLength = 64 ) -// APIPortalOAuth2RequiredAuthConfigKeys are the keys the oauth2 flow must -// supply in authConfig at Create time (or on Update when auth_type is being -// changed to oauth2). Order is stable so validation error messages list -// missing fields in a predictable sequence. -var APIPortalOAuth2RequiredAuthConfigKeys = []string{ - APIPortalAuthConfigKeySTSTokenURL, - APIPortalAuthConfigKeyClientID, - APIPortalAuthConfigKeyClientSecret, -} - -// APIPortalAuthConfigSensitiveKeys lists the authConfig keys whose values are -// treated as secrets: encrypted at rest via the platform vault and stripped -// from any response. Independent of auth_type — the set is small and the -// keys are the same shape across types. -var APIPortalAuthConfigSensitiveKeys = []string{ - APIPortalAuthConfigKeyClientSecret, -} - -// API Portal auth type constants -const ( - APIPortalAuthTypeLocal = "local" - APIPortalAuthTypeOAuth2 = "oauth2" -) - -// ValidAPIPortalAuthTypes holds accepted values for api_portals.auth_type -var ValidAPIPortalAuthTypes = map[string]bool{ - APIPortalAuthTypeLocal: true, - APIPortalAuthTypeOAuth2: true, -} - // ValidArtifactKinds holds accepted values for artifacts.type for the core (non-plugin) // artifact kinds. Plugin-owned kinds (e.g. WebSubApi, WebBrokerApi) are registered // into the ArtifactTableRegistry during plugin Init. diff --git a/platform-api/internal/database/schema.postgres.sql b/platform-api/internal/database/schema.postgres.sql index 816632c93d..5035264125 100644 --- a/platform-api/internal/database/schema.postgres.sql +++ b/platform-api/internal/database/schema.postgres.sql @@ -407,14 +407,13 @@ CREATE TABLE IF NOT EXISTS api_portals ( display_name VARCHAR(255) NOT NULL, description VARCHAR(1023), url VARCHAR(500), - status VARCHAR(20) NOT NULL DEFAULT 'pending', - auth_type VARCHAR(20) NOT NULL, - auth_configuration BYTEA NOT NULL, - metadata BYTEA NOT NULL, - created_by VARCHAR(200), - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_by VARCHAR(200), - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + internal_auth_key BYTEA NOT NULL, + metadata BYTEA NOT NULL, + created_by VARCHAR(200), + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_by VARCHAR(200), + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, UNIQUE (organization_uuid, handle) ); diff --git a/platform-api/internal/database/schema.sqlite.sql b/platform-api/internal/database/schema.sqlite.sql index 7469009df0..4aa0f856b9 100644 --- a/platform-api/internal/database/schema.sqlite.sql +++ b/platform-api/internal/database/schema.sqlite.sql @@ -407,14 +407,13 @@ CREATE TABLE IF NOT EXISTS api_portals ( display_name VARCHAR(255) NOT NULL, description VARCHAR(1023), url VARCHAR(500), - status VARCHAR(20) NOT NULL DEFAULT 'pending', - auth_type VARCHAR(20) NOT NULL, - auth_configuration BLOB NOT NULL, - metadata BLOB NOT NULL, - created_by VARCHAR(200), - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_by VARCHAR(200), - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + internal_auth_key BLOB NOT NULL, + metadata BLOB NOT NULL, + created_by VARCHAR(200), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_by VARCHAR(200), + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, UNIQUE (organization_uuid, handle) ); diff --git a/platform-api/internal/database/schema.sqlserver.sql b/platform-api/internal/database/schema.sqlserver.sql index 898cb29209..f22ad20cd5 100644 --- a/platform-api/internal/database/schema.sqlserver.sql +++ b/platform-api/internal/database/schema.sqlserver.sql @@ -459,14 +459,13 @@ CREATE TABLE dbo.api_portals ( display_name VARCHAR(255) NOT NULL, description VARCHAR(1023), url VARCHAR(500), - status VARCHAR(20) NOT NULL DEFAULT 'pending', - auth_type VARCHAR(20) NOT NULL, - auth_configuration VARBINARY(MAX) NOT NULL, - metadata VARBINARY(MAX) NOT NULL, - created_by VARCHAR(200), - created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), - updated_by VARCHAR(200), - updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), + status VARCHAR(20) NOT NULL DEFAULT 'pending', + internal_auth_key VARBINARY(MAX) NOT NULL, + metadata VARBINARY(MAX) NOT NULL, + created_by VARCHAR(200), + created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), + updated_by VARCHAR(200), + updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, UNIQUE (organization_uuid, handle) ); diff --git a/platform-api/internal/handler/api_portal_integration_test.go b/platform-api/internal/handler/api_portal_integration_test.go deleted file mode 100644 index 78f9d60a09..0000000000 --- a/platform-api/internal/handler/api_portal_integration_test.go +++ /dev/null @@ -1,508 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. - * - * Licensed 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. - * - */ - -// Integration tests for the /api-portals handler, covering the full -// route → handler → service → repository stack backed by SQLite. - -package handler - -import ( - "bytes" - "database/sql" - "encoding/json" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/wso2/api-platform/platform-api/internal/database" - "github.com/wso2/api-platform/platform-api/internal/middleware" - "github.com/wso2/api-platform/platform-api/internal/repository" - "github.com/wso2/api-platform/platform-api/internal/service" - "github.com/wso2/api-platform/platform-api/internal/vault" - - _ "github.com/mattn/go-sqlite3" -) - -// apiPortalTestVault returns a deterministic in-house vault for integration tests. -func apiPortalTestVault(t *testing.T) vault.SecretVault { - t.Helper() - v, err := vault.NewInHouseVault(bytes.Repeat([]byte("t"), 32)) - if err != nil { - t.Fatalf("test vault: %v", err) - } - return v -} - -const apiPortalTestBase = "/api/v0.9/api-portals" -const apiPortalTestOrg = "org-portal-it" -const apiPortalTestUser = "sub-portal-tester" - -// setupAPIPortalHandlerEnv brings up the full API-Portal handler stack against a -// fresh SQLite database and seeds the parent organization row the FK requires. -func setupAPIPortalHandlerEnv(t *testing.T) (http.Handler, *database.DB, func()) { - t.Helper() - - dbPath := filepath.Join(t.TempDir(), "api-portal-test.db") - sqlDB, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on") - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - db := &database.DB{DB: sqlDB} - - schema, err := os.ReadFile(filepath.Join("..", "database", "schema.sqlite.sql")) - if err != nil { - t.Fatalf("read schema: %v", err) - } - if _, err = db.Exec(string(schema)); err != nil { - t.Fatalf("apply schema: %v", err) - } - if _, err = db.Exec( - `INSERT INTO organizations (uuid, handle, display_name, region, idp_organization_ref_uuid, created_at, updated_at) - VALUES (?, ?, 'Portal Test Org', 'default', 'idp-ref', datetime('now'), datetime('now'))`, - apiPortalTestOrg, "test-org-"+apiPortalTestOrg, - ); err != nil { - t.Fatalf("insert org: %v", err) - } - - portalRepo := repository.NewAPIPortalRepo(db) - orgRepo := repository.NewOrganizationRepo(db) - identityService := service.NewIdentityService(repository.NewUserIdentityMappingRepo(db)) - svc := service.NewAPIPortalService(portalRepo, orgRepo, noopAudit{}, apiPortalTestVault(t), nil, identityService, slog.Default()) - h := NewAPIPortalHandler(svc, identityService, slog.Default()) - - mux := http.NewServeMux() - h.RegisterRoutes(mux) - return middleware.NewTestContextMiddleware(mux), db, func() { _ = sqlDB.Close() } -} - -// apiPortalTestRequest builds a request with the test auth headers set. -func apiPortalTestRequest(t *testing.T, method, path string, body []byte) *http.Request { - t.Helper() - var r *http.Request - if body != nil { - r = httptest.NewRequest(method, path, bytes.NewReader(body)) - r.Header.Set("Content-Type", "application/json") - } else { - r = httptest.NewRequest(method, path, nil) - } - r.Header.Set("X-Test-User", apiPortalTestUser) - r.Header.Set("X-Test-Org", apiPortalTestOrg) - return r -} - -func mustJSON(t *testing.T, v any) []byte { - t.Helper() - b, err := json.Marshal(v) - if err != nil { - t.Fatalf("marshal: %v", err) - } - return b -} - -// Minimal response shapes for decoding — mirror the fields the handler emits. -// Using a dedicated local shape avoids the pointer maze of api.ApiPortalResponse. -type apiPortalResp struct { - Id string `json:"id"` - Handle string `json:"handle"` - Name string `json:"name"` - Description *string `json:"description,omitempty"` - Url string `json:"url"` - AuthType string `json:"authType"` - AuthConfig map[string]interface{} `json:"authConfig,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` -} - -type apiPortalListResp struct { - Count int `json:"count"` - List []apiPortalResp `json:"list"` - Pagination struct { - Total int `json:"total"` - Offset int `json:"offset"` - Limit int `json:"limit"` - } `json:"pagination"` -} - -type apiPortalErrorResp struct { - Code string `json:"code"` - Message string `json:"message"` -} - -// --- CREATE --- - -func TestAPIPortalHandler_Create_HappyPath(t *testing.T) { - r, _, cleanup := setupAPIPortalHandlerEnv(t) - t.Cleanup(cleanup) - - // local auth type must have empty authConfig; use metadata for round-trip check. - body := mustJSON(t, map[string]any{ - "name": "Acme Portal", - "handle": "acme", - "url": "https://acme.example.com", - "authType": "local", - "metadata": map[string]any{"stsIssuer": "https://sts.example.com"}, - }) - req := apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body) - rec := httptest.NewRecorder() - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusCreated { - t.Fatalf("Create: want 201, got %d: %s", rec.Code, rec.Body.String()) - } - loc := rec.Header().Get("Location") - if !strings.HasSuffix(loc, "/api-portals/acme") { - t.Errorf("Location header wrong: %q", loc) - } - var got apiPortalResp - if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v", err) - } - if got.Id != "acme" || got.Handle != "acme" || got.Name != "Acme Portal" || - got.AuthType != "local" || got.Url != "https://acme.example.com" { - t.Errorf("response fields wrong: %+v", got) - } - if got.Metadata["stsIssuer"] != "https://sts.example.com" { - t.Errorf("metadata round-trip failed: %v", got.Metadata) - } -} - -func TestAPIPortalHandler_Create_OAuth2_EncryptsClientSecret(t *testing.T) { - r, db, cleanup := setupAPIPortalHandlerEnv(t) - t.Cleanup(cleanup) - - // oauth2 authConfig with a plaintext clientSecret. - body := mustJSON(t, map[string]any{ - "name": "Acme OAuth", - "handle": "acme-oauth", - "authType": "oauth2", - "url": "https://acme.example.com", - "authConfig": map[string]any{ - "stsTokenUrl": "https://sts.example.com/token", - "clientId": "abc", - "clientSecret": "s3cr3t-plaintext", - }, - }) - rec := httptest.NewRecorder() - r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body)) - if rec.Code != http.StatusCreated { - t.Fatalf("Create: want 201, got %d: %s", rec.Code, rec.Body.String()) - } - - // Response must NOT include clientSecret; other authConfig fields visible. - var got apiPortalResp - if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v", err) - } - if got.AuthConfig["stsTokenUrl"] != "https://sts.example.com/token" || - got.AuthConfig["clientId"] != "abc" { - t.Errorf("non-secret authConfig fields missing in response: %+v", got.AuthConfig) - } - if _, present := got.AuthConfig["clientSecret"]; present { - t.Errorf("clientSecret leaked in response body: %v", got.AuthConfig) - } - - // DB must NOT contain plaintext secret. - var authCfgBlob []byte - if err := db.QueryRow(`SELECT auth_configuration FROM api_portals WHERE handle = 'acme-oauth'`).Scan(&authCfgBlob); err != nil { - t.Fatalf("query auth_configuration: %v", err) - } - if strings.Contains(string(authCfgBlob), "s3cr3t-plaintext") { - t.Errorf("plaintext clientSecret found in DB blob: %s", authCfgBlob) - } -} - -func TestAPIPortalHandler_Create_MissingName(t *testing.T) { - r, _, cleanup := setupAPIPortalHandlerEnv(t) - t.Cleanup(cleanup) - - body := mustJSON(t, map[string]any{ - "handle": "acme", - "url": "https://acme.example.com", - "authType": "local", - }) - req := apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body) - rec := httptest.NewRecorder() - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusBadRequest { - t.Fatalf("want 400 for missing name, got %d: %s", rec.Code, rec.Body.String()) - } -} - -func TestAPIPortalHandler_Create_MissingURL(t *testing.T) { - r, _, cleanup := setupAPIPortalHandlerEnv(t) - t.Cleanup(cleanup) - - body := mustJSON(t, map[string]any{ - "name": "Acme Portal", - "handle": "acme-nourl", - "authType": "local", - }) - req := apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body) - rec := httptest.NewRecorder() - r.ServeHTTP(rec, req) - if rec.Code != http.StatusBadRequest { - t.Fatalf("Create: want 400 for missing url, got %d: %s", rec.Code, rec.Body.String()) - } -} - -func TestAPIPortalHandler_Create_HandleConflict(t *testing.T) { - r, _, cleanup := setupAPIPortalHandlerEnv(t) - t.Cleanup(cleanup) - - body := mustJSON(t, map[string]any{ - "name": "a", - "handle": "dup", - "url": "https://a.example.com", - "authType": "local", - }) - req := apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body) - rec := httptest.NewRecorder() - r.ServeHTTP(rec, req) - if rec.Code != http.StatusCreated { - t.Fatalf("first Create: want 201, got %d: %s", rec.Code, rec.Body.String()) - } - - // Second POST with the same handle must be 409. - req2 := apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body) - rec2 := httptest.NewRecorder() - r.ServeHTTP(rec2, req2) - if rec2.Code != http.StatusConflict { - t.Fatalf("duplicate Create: want 409, got %d: %s", rec2.Code, rec2.Body.String()) - } - var errBody apiPortalErrorResp - if err := json.Unmarshal(rec2.Body.Bytes(), &errBody); err != nil { - t.Fatalf("decode error body: %v", err) - } - if errBody.Code != "API_PORTAL_EXISTS" { - t.Errorf("error code: want API_PORTAL_EXISTS, got %q", errBody.Code) - } -} - -func TestAPIPortalHandler_Create_MissingOrg(t *testing.T) { - r, _, cleanup := setupAPIPortalHandlerEnv(t) - t.Cleanup(cleanup) - - body := mustJSON(t, map[string]any{ - "name": "a", - "handle": "acme", - "url": "https://acme.example.com", - "authType": "local", - }) - // Deliberately DO NOT set X-Test-Org; expect 401 from the handler's org guard. - req := httptest.NewRequest(http.MethodPost, apiPortalTestBase, bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Test-User", apiPortalTestUser) - rec := httptest.NewRecorder() - r.ServeHTTP(rec, req) - if rec.Code != http.StatusUnauthorized { - t.Fatalf("want 401 for missing org context, got %d: %s", rec.Code, rec.Body.String()) - } -} - -// --- GET (single) --- - -func TestAPIPortalHandler_Get_HappyPath(t *testing.T) { - r, _, cleanup := setupAPIPortalHandlerEnv(t) - t.Cleanup(cleanup) - - // Seed via POST. - body := mustJSON(t, map[string]any{ - "name": "Acme", - "handle": "acme", - "url": "https://acme.example.com", - "authType": "local", - }) - rec := httptest.NewRecorder() - r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body)) - if rec.Code != http.StatusCreated { - t.Fatalf("seed Create failed: %d %s", rec.Code, rec.Body.String()) - } - - getRec := httptest.NewRecorder() - r.ServeHTTP(getRec, apiPortalTestRequest(t, http.MethodGet, apiPortalTestBase+"/acme", nil)) - if getRec.Code != http.StatusOK { - t.Fatalf("Get: want 200, got %d: %s", getRec.Code, getRec.Body.String()) - } - var got apiPortalResp - if err := json.Unmarshal(getRec.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v", err) - } - if got.Handle != "acme" || got.Name != "Acme" { - t.Errorf("Get response wrong: %+v", got) - } -} - -func TestAPIPortalHandler_Get_NotFound(t *testing.T) { - r, _, cleanup := setupAPIPortalHandlerEnv(t) - t.Cleanup(cleanup) - - rec := httptest.NewRecorder() - r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodGet, apiPortalTestBase+"/ghost", nil)) - if rec.Code != http.StatusNotFound { - t.Fatalf("Get missing: want 404, got %d: %s", rec.Code, rec.Body.String()) - } - var errBody apiPortalErrorResp - if err := json.Unmarshal(rec.Body.Bytes(), &errBody); err != nil { - t.Fatalf("decode error body: %v", err) - } - if errBody.Code != "API_PORTAL_NOT_FOUND" { - t.Errorf("error code: want API_PORTAL_NOT_FOUND, got %q", errBody.Code) - } -} - -// --- LIST --- - -func TestAPIPortalHandler_List_HappyPath(t *testing.T) { - r, _, cleanup := setupAPIPortalHandlerEnv(t) - t.Cleanup(cleanup) - - // Seed 3 portals. - for _, h := range []string{"one", "two", "three"} { - body := mustJSON(t, map[string]any{ - "name": "P " + h, - "handle": h, - "url": "https://" + h + ".example.com", - "authType": "local", - }) - rec := httptest.NewRecorder() - r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body)) - if rec.Code != http.StatusCreated { - t.Fatalf("seed %s: %d %s", h, rec.Code, rec.Body.String()) - } - } - - rec := httptest.NewRecorder() - r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodGet, apiPortalTestBase, nil)) - if rec.Code != http.StatusOK { - t.Fatalf("List: want 200, got %d: %s", rec.Code, rec.Body.String()) - } - var got apiPortalListResp - if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v", err) - } - if got.Count != 3 || got.Pagination.Total != 3 || len(got.List) != 3 { - t.Errorf("counts wrong: %+v", got) - } - if got.Pagination.Limit != 20 { - t.Errorf("default limit: want 20, got %d", got.Pagination.Limit) - } -} - -// --- UPDATE --- - -func TestAPIPortalHandler_Update_HappyPath(t *testing.T) { - r, _, cleanup := setupAPIPortalHandlerEnv(t) - t.Cleanup(cleanup) - - // Seed. - body := mustJSON(t, map[string]any{ - "name": "old", - "handle": "acme", - "url": "https://acme.example.com", - "authType": "local", - }) - rec := httptest.NewRecorder() - r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body)) - if rec.Code != http.StatusCreated { - t.Fatalf("seed: %d %s", rec.Code, rec.Body.String()) - } - - // Update name + authType — swapping to oauth2 requires supplying a full authConfig. - patch := mustJSON(t, map[string]any{ - "name": "new", - "authType": "oauth2", - "authConfig": map[string]any{ - "stsTokenUrl": "https://sts.example.com/token", - "clientId": "abc", - "clientSecret": "s3cr3t", - }, - }) - putRec := httptest.NewRecorder() - r.ServeHTTP(putRec, apiPortalTestRequest(t, http.MethodPut, apiPortalTestBase+"/acme", patch)) - if putRec.Code != http.StatusOK { - t.Fatalf("Update: want 200, got %d: %s", putRec.Code, putRec.Body.String()) - } - var got apiPortalResp - if err := json.Unmarshal(putRec.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v", err) - } - if got.Name != "new" || got.AuthType != "oauth2" { - t.Errorf("mutable fields not applied: %+v", got) - } - if got.Handle != "acme" { - t.Errorf("handle mutated: %q", got.Handle) - } -} - -func TestAPIPortalHandler_Update_NotFound(t *testing.T) { - r, _, cleanup := setupAPIPortalHandlerEnv(t) - t.Cleanup(cleanup) - - patch := mustJSON(t, map[string]any{"name": "x"}) - rec := httptest.NewRecorder() - r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodPut, apiPortalTestBase+"/ghost", patch)) - if rec.Code != http.StatusNotFound { - t.Fatalf("want 404, got %d: %s", rec.Code, rec.Body.String()) - } -} - -// --- DELETE --- - -func TestAPIPortalHandler_Delete_HappyPath(t *testing.T) { - r, _, cleanup := setupAPIPortalHandlerEnv(t) - t.Cleanup(cleanup) - - body := mustJSON(t, map[string]any{ - "name": "x", - "handle": "gone", - "url": "https://gone.example.com", - "authType": "local", - }) - rec := httptest.NewRecorder() - r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body)) - if rec.Code != http.StatusCreated { - t.Fatalf("seed: %d %s", rec.Code, rec.Body.String()) - } - - delRec := httptest.NewRecorder() - r.ServeHTTP(delRec, apiPortalTestRequest(t, http.MethodDelete, apiPortalTestBase+"/gone", nil)) - if delRec.Code != http.StatusNoContent { - t.Fatalf("Delete: want 204, got %d: %s", delRec.Code, delRec.Body.String()) - } - - // Subsequent Get is 404. - getRec := httptest.NewRecorder() - r.ServeHTTP(getRec, apiPortalTestRequest(t, http.MethodGet, apiPortalTestBase+"/gone", nil)) - if getRec.Code != http.StatusNotFound { - t.Fatalf("Get after Delete: want 404, got %d", getRec.Code) - } -} - -func TestAPIPortalHandler_Delete_NotFound(t *testing.T) { - r, _, cleanup := setupAPIPortalHandlerEnv(t) - t.Cleanup(cleanup) - - rec := httptest.NewRecorder() - r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodDelete, apiPortalTestBase+"/ghost", nil)) - if rec.Code != http.StatusNotFound { - t.Fatalf("Delete missing: want 404, got %d: %s", rec.Code, rec.Body.String()) - } -} diff --git a/platform-api/internal/model/api_portal.go b/platform-api/internal/model/api_portal.go index 5f16e6eaf9..d886c4f463 100644 --- a/platform-api/internal/model/api_portal.go +++ b/platform-api/internal/model/api_portal.go @@ -26,28 +26,28 @@ import ( // APIPortal represents an API Portal registered within an organization. // // Two persisted blobs, split by consumer: -// - AuthConfig is consumed by Platform-API's outbound AuthProvider path. -// Shape depends on auth_type: `local` = empty; `oauth2` = stsTokenUrl, -// clientId, clientSecret. Sensitive values (clientSecret) are stored -// encrypted; the plaintext key is never returned in responses. +// - InternalAuthKey is the encrypted raw shared key Platform-API sends as +// `Authorization: SharedKey ` on outbound publishing calls. Stored +// as AES-GCM ciphertext (nonce || ciphertext) via internal/vault; the +// plaintext key is only ever handed to the caller ONCE at Create/Update +// time and never returned on any read path. // - Metadata is opaque pass-through data (never encrypted, always returned). // Typically carries the cloud-side OIDC endpoints that the portal pod uses // for consumer login (stsIssuer, stsJwksUrl, etc.); usually empty in OSS. type APIPortal struct { - ID string `json:"id" db:"uuid"` - OrganizationID string `json:"organizationId" db:"organization_uuid"` - Handle string `json:"handle" db:"handle"` - Name string `json:"name" db:"display_name"` - Description string `json:"description,omitempty" db:"description"` - URL string `json:"url,omitempty" db:"url"` - Status string `json:"status" db:"status"` - AuthType string `json:"authType" db:"auth_type"` - AuthConfig map[string]interface{} `json:"authConfig,omitempty" db:"auth_configuration"` - Metadata map[string]interface{} `json:"metadata,omitempty" db:"metadata"` - CreatedBy string `json:"createdBy,omitempty" db:"created_by"` - UpdatedBy string `json:"updatedBy,omitempty" db:"updated_by"` - CreatedAt time.Time `json:"createdAt" db:"created_at"` - UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` + ID string `json:"id" db:"uuid"` + OrganizationID string `json:"organizationId" db:"organization_uuid"` + Handle string `json:"handle" db:"handle"` + Name string `json:"name" db:"display_name"` + Description string `json:"description,omitempty" db:"description"` + URL string `json:"url,omitempty" db:"url"` + Status string `json:"status" db:"status"` + InternalAuthKey []byte `json:"-" db:"internal_auth_key"` + Metadata map[string]interface{} `json:"metadata,omitempty" db:"metadata"` + CreatedBy string `json:"createdBy,omitempty" db:"created_by"` + UpdatedBy string `json:"updatedBy,omitempty" db:"updated_by"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` } // TableName returns the table name for the APIPortal model. diff --git a/platform-api/internal/repository/api_portal.go b/platform-api/internal/repository/api_portal.go index 1991c3601b..bd88e9224b 100644 --- a/platform-api/internal/repository/api_portal.go +++ b/platform-api/internal/repository/api_portal.go @@ -42,7 +42,7 @@ func NewAPIPortalRepo(db *database.DB) APIPortalRepository { // apiPortalSelectColumns are the api_portals columns selected in every query, in scan order. const apiPortalSelectColumns = ` uuid, organization_uuid, handle, display_name, description, url, - status, auth_type, auth_configuration, metadata, + status, internal_auth_key, metadata, created_by, updated_by, created_at, updated_at ` @@ -52,10 +52,10 @@ func scanAPIPortalRow(scanner interface { }) (*model.APIPortal, error) { portal := &model.APIPortal{} var description, url, createdBy, updatedBy sql.NullString - var authConfigBytes, metadataBytes []byte + var metadataBytes []byte if err := scanner.Scan( &portal.ID, &portal.OrganizationID, &portal.Handle, &portal.Name, &description, &url, - &portal.Status, &portal.AuthType, &authConfigBytes, &metadataBytes, + &portal.Status, &portal.InternalAuthKey, &metadataBytes, &createdBy, &updatedBy, &portal.CreatedAt, &portal.UpdatedAt, ); err != nil { return nil, err @@ -64,11 +64,6 @@ func scanAPIPortalRow(scanner interface { portal.URL = url.String portal.CreatedBy = createdBy.String portal.UpdatedBy = updatedBy.String - authConfig, err := unmarshalAPIPortalBlob(authConfigBytes, "auth_configuration") - if err != nil { - return nil, err - } - portal.AuthConfig = authConfig metadata, err := unmarshalAPIPortalBlob(metadataBytes, "metadata") if err != nil { return nil, err @@ -112,23 +107,19 @@ func (r *APIPortalRepo) Create(portal *model.APIPortal) error { now := time.Now().UTC() portal.CreatedAt = now portal.UpdatedAt = now - authConfigBytes, err := marshalAPIPortalBlob(portal.AuthConfig, "auth_configuration") - if err != nil { - return err - } metadataBytes, err := marshalAPIPortalBlob(portal.Metadata, "metadata") if err != nil { return err } query := ` INSERT INTO api_portals (uuid, organization_uuid, handle, display_name, description, url, - status, auth_type, auth_configuration, metadata, + status, internal_auth_key, metadata, created_by, updated_by, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` _, err = r.db.Exec(r.db.Rebind(query), portal.ID, portal.OrganizationID, portal.Handle, portal.Name, portal.Description, portal.URL, - portal.Status, portal.AuthType, authConfigBytes, metadataBytes, + portal.Status, portal.InternalAuthKey, metadataBytes, portal.CreatedBy, portal.UpdatedBy, portal.CreatedAt, portal.UpdatedAt, ) return err @@ -223,14 +214,10 @@ func (r *APIPortalRepo) Count(orgUUID string, search string) (int, error) { } // Update mutates only the whitelisted fields; immutable columns (uuid, organization_uuid, -// handle, data_version, created_by, created_at) are never touched. The caller is +// handle, created_by, created_at) are never touched. The caller is // responsible for populating UpdatedBy before invoking. func (r *APIPortalRepo) Update(portal *model.APIPortal) error { portal.UpdatedAt = time.Now().UTC() - authConfigBytes, err := marshalAPIPortalBlob(portal.AuthConfig, "auth_configuration") - if err != nil { - return err - } metadataBytes, err := marshalAPIPortalBlob(portal.Metadata, "metadata") if err != nil { return err @@ -238,13 +225,13 @@ func (r *APIPortalRepo) Update(portal *model.APIPortal) error { query := ` UPDATE api_portals SET display_name = ?, description = ?, url = ?, status = ?, - auth_type = ?, auth_configuration = ?, metadata = ?, + internal_auth_key = ?, metadata = ?, updated_by = ?, updated_at = ? WHERE uuid = ? AND organization_uuid = ? ` result, err := r.db.Exec(r.db.Rebind(query), portal.Name, portal.Description, portal.URL, portal.Status, - portal.AuthType, authConfigBytes, metadataBytes, + portal.InternalAuthKey, metadataBytes, portal.UpdatedBy, portal.UpdatedAt, portal.ID, portal.OrganizationID, ) diff --git a/platform-api/internal/repository/api_portal_test.go b/platform-api/internal/repository/api_portal_test.go deleted file mode 100644 index fcb4ee0530..0000000000 --- a/platform-api/internal/repository/api_portal_test.go +++ /dev/null @@ -1,494 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. - * - * Licensed 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 repository - -import ( - "strings" - "testing" - "time" - - "github.com/wso2/api-platform/platform-api/internal/constants" - "github.com/wso2/api-platform/platform-api/internal/database" - "github.com/wso2/api-platform/platform-api/internal/model" -) - -// createTestAPIPortalOrg inserts the organization row api_portals references via its FK. -// The organizations table has no other prerequisite so this is a single INSERT. -func createTestAPIPortalOrg(t *testing.T, db *database.DB, orgUUID string) { - t.Helper() - q := ` - INSERT INTO organizations (uuid, handle, display_name, region, idp_organization_ref_uuid, created_at, updated_at) - VALUES (?, ?, ?, 'default', 'idp-ref', datetime('now'), datetime('now')) - ` - if _, err := db.Exec(q, orgUUID, "test-org-"+orgUUID, "Test Org"); err != nil { - t.Fatalf("failed to insert test organization: %v", err) - } -} - -// newTestAPIPortal returns a valid *model.APIPortal with sensible defaults. -// Individual tests override the fields they care about. -func newTestAPIPortal(uuid, orgUUID, handle string) *model.APIPortal { - return &model.APIPortal{ - ID: uuid, - OrganizationID: orgUUID, - Handle: handle, - Name: "Portal " + handle, - Description: "test portal", - URL: "https://" + handle + ".example.com", - Status: constants.APIPortalStatusPending, - AuthType: constants.APIPortalAuthTypeLocal, - AuthConfig: map[string]interface{}{"foo": "bar"}, - CreatedBy: "tester", - UpdatedBy: "tester", - } -} - -func TestAPIPortalRepo_CreateAndGet(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - const orgUUID = "org-portal-crud" - createTestAPIPortalOrg(t, db, orgUUID) - - repo := NewAPIPortalRepo(db) - portal := newTestAPIPortal("portal-001", orgUUID, "acme") - if err := repo.Create(portal); err != nil { - t.Fatalf("Create: %v", err) - } - - // Get by UUID. - got, err := repo.GetByUUID(portal.ID, orgUUID) - if err != nil { - t.Fatalf("GetByUUID: %v", err) - } - if got == nil { - t.Fatal("GetByUUID: expected row, got nil") - } - if got.Handle != portal.Handle || got.Name != portal.Name || got.URL != portal.URL { - t.Errorf("GetByUUID: field mismatch; got %+v", got) - } - if got.AuthConfig["foo"] != "bar" { - t.Errorf("configuration not round-tripped; got %v", got.AuthConfig) - } - - // Get by handle. - got2, err := repo.GetByHandleAndOrgID(portal.Handle, orgUUID) - if err != nil { - t.Fatalf("GetByHandleAndOrgID: %v", err) - } - if got2 == nil || got2.ID != portal.ID { - t.Errorf("GetByHandleAndOrgID mismatch; got %+v", got2) - } -} - -func TestAPIPortalRepo_Create_SetsDefaults(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - const orgUUID = "org-portal-defaults" - createTestAPIPortalOrg(t, db, orgUUID) - - repo := NewAPIPortalRepo(db) - portal := newTestAPIPortal("portal-defaults", orgUUID, "defaults") - // Explicitly leave timestamps zero; expect Create to populate them. - portal.CreatedAt = time.Time{} - portal.UpdatedAt = time.Time{} - - before := time.Now().UTC().Add(-time.Second) - if err := repo.Create(portal); err != nil { - t.Fatalf("Create: %v", err) - } - after := time.Now().UTC().Add(time.Second) - - if portal.CreatedAt.Before(before) || portal.CreatedAt.After(after) { - t.Errorf("CreatedAt not set to ~now: got %v", portal.CreatedAt) - } - if portal.UpdatedAt.Before(before) || portal.UpdatedAt.After(after) { - t.Errorf("UpdatedAt not set to ~now: got %v", portal.UpdatedAt) - } -} - -func TestAPIPortalRepo_Create_AuthConfigRoundTrip_Nil(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - const orgUUID = "org-portal-cfg-nil" - createTestAPIPortalOrg(t, db, orgUUID) - - repo := NewAPIPortalRepo(db) - portal := newTestAPIPortal("portal-cfg-nil", orgUUID, "cfg-nil") - portal.AuthConfig = nil // will be stored as {} and read back as empty map - - if err := repo.Create(portal); err != nil { - t.Fatalf("Create: %v", err) - } - got, err := repo.GetByUUID(portal.ID, orgUUID) - if err != nil { - t.Fatalf("GetByUUID: %v", err) - } - if got.AuthConfig == nil { - t.Fatal("AuthConfig is nil after round-trip; expected non-nil empty map") - } - if len(got.AuthConfig) != 0 { - t.Errorf("AuthConfig expected empty; got %v", got.AuthConfig) - } -} - -func TestAPIPortalRepo_Create_AuthConfigRoundTrip_Populated(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - const orgUUID = "org-portal-cfg-full" - createTestAPIPortalOrg(t, db, orgUUID) - - repo := NewAPIPortalRepo(db) - portal := newTestAPIPortal("portal-cfg-full", orgUUID, "cfg-full") - portal.AuthConfig = map[string]interface{}{ - "stsTokenUrl": "https://sts.example.com/token", - "clientId": "abc", - "audience": []interface{}{"aud-1", "aud-2"}, - } - - if err := repo.Create(portal); err != nil { - t.Fatalf("Create: %v", err) - } - got, err := repo.GetByUUID(portal.ID, orgUUID) - if err != nil { - t.Fatalf("GetByUUID: %v", err) - } - if got.AuthConfig["stsTokenUrl"] != "https://sts.example.com/token" { - t.Errorf("stsTokenUrl round-trip failed; got %v", got.AuthConfig["stsTokenUrl"]) - } - if got.AuthConfig["clientId"] != "abc" { - t.Errorf("clientId round-trip failed; got %v", got.AuthConfig["clientId"]) - } - aud, ok := got.AuthConfig["audience"].([]interface{}) - if !ok || len(aud) != 2 || aud[0] != "aud-1" || aud[1] != "aud-2" { - t.Errorf("audience round-trip failed; got %v", got.AuthConfig["audience"]) - } -} - -func TestAPIPortalRepo_Create_DuplicateHandle(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - const orgUUID = "org-portal-dup" - createTestAPIPortalOrg(t, db, orgUUID) - - repo := NewAPIPortalRepo(db) - if err := repo.Create(newTestAPIPortal("portal-dup-1", orgUUID, "dup")); err != nil { - t.Fatalf("first Create: %v", err) - } - err := repo.Create(newTestAPIPortal("portal-dup-2", orgUUID, "dup")) - if err == nil { - t.Fatal("expected duplicate handle to fail, got nil") - } - if !IsUniqueViolation(err) { - t.Errorf("expected unique-constraint violation, got %v", err) - } -} - -func TestAPIPortalRepo_Get_NotFound(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - const orgUUID = "org-portal-nf" - createTestAPIPortalOrg(t, db, orgUUID) - - repo := NewAPIPortalRepo(db) - got, err := repo.GetByUUID("does-not-exist", orgUUID) - if err != nil { - t.Fatalf("GetByUUID: unexpected error: %v", err) - } - if got != nil { - t.Errorf("GetByUUID: expected nil for missing row, got %+v", got) - } - got2, err := repo.GetByHandleAndOrgID("no-such-handle", orgUUID) - if err != nil { - t.Fatalf("GetByHandleAndOrgID: unexpected error: %v", err) - } - if got2 != nil { - t.Errorf("GetByHandleAndOrgID: expected nil for missing row, got %+v", got2) - } -} - -func TestAPIPortalRepo_Get_CrossOrgIsolation(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - const orgA = "org-portal-a" - const orgB = "org-portal-b" - createTestAPIPortalOrg(t, db, orgA) - createTestAPIPortalOrg(t, db, orgB) - - repo := NewAPIPortalRepo(db) - if err := repo.Create(newTestAPIPortal("portal-a", orgA, "shared-handle")); err != nil { - t.Fatalf("Create A: %v", err) - } - if err := repo.Create(newTestAPIPortal("portal-b", orgB, "shared-handle")); err != nil { - t.Fatalf("Create B (different org, same handle allowed): %v", err) - } - // A's portal-a must not be visible when querying org B. - got, err := repo.GetByUUID("portal-a", orgB) - if err != nil { - t.Fatalf("GetByUUID cross-org: %v", err) - } - if got != nil { - t.Errorf("cross-org leak: got %+v", got) - } -} - -func TestAPIPortalRepo_ListPaginated(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - const orgUUID = "org-portal-list" - createTestAPIPortalOrg(t, db, orgUUID) - - repo := NewAPIPortalRepo(db) - // Insert 5 portals with staggered createdAt to make ordering deterministic. - for i, handle := range []string{"aa", "bb", "cc", "dd", "ee"} { - p := newTestAPIPortal("portal-"+handle, orgUUID, handle) - if err := repo.Create(p); err != nil { - t.Fatalf("Create %s: %v", handle, err) - } - // Nudge each row's created_at forward so DESC ordering is stable. - p.CreatedAt = time.Now().UTC().Add(time.Duration(i) * time.Millisecond) - if _, err := db.Exec(`UPDATE api_portals SET created_at = ? WHERE uuid = ?`, p.CreatedAt, p.ID); err != nil { - t.Fatalf("nudge created_at: %v", err) - } - } - - // Page 1: limit 2 → newest first ("ee", "dd"). - page1, err := repo.ListPaginated(orgUUID, ListOptions{Limit: 2, Offset: 0}) - if err != nil { - t.Fatalf("ListPaginated page 1: %v", err) - } - if len(page1) != 2 { - t.Fatalf("page 1 size: want 2, got %d", len(page1)) - } - if page1[0].Handle != "ee" || page1[1].Handle != "dd" { - t.Errorf("page 1 order: got %s, %s", page1[0].Handle, page1[1].Handle) - } - - // Page 2: offset 2, limit 2 → "cc", "bb". - page2, err := repo.ListPaginated(orgUUID, ListOptions{Limit: 2, Offset: 2}) - if err != nil { - t.Fatalf("ListPaginated page 2: %v", err) - } - if len(page2) != 2 || page2[0].Handle != "cc" || page2[1].Handle != "bb" { - t.Errorf("page 2: %+v", page2) - } - - // Count without filter. - total, err := repo.Count(orgUUID, "") - if err != nil { - t.Fatalf("Count: %v", err) - } - if total != 5 { - t.Errorf("Count: want 5, got %d", total) - } -} - -func TestAPIPortalRepo_ListPaginated_Search(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - const orgUUID = "org-portal-search" - createTestAPIPortalOrg(t, db, orgUUID) - - repo := NewAPIPortalRepo(db) - for _, h := range []string{"acme-dev", "acme-prod", "other-portal"} { - if err := repo.Create(newTestAPIPortal("portal-"+h, orgUUID, h)); err != nil { - t.Fatalf("Create %s: %v", h, err) - } - } - got, err := repo.ListPaginated(orgUUID, ListOptions{Limit: 10, Offset: 0, Search: "acme"}) - if err != nil { - t.Fatalf("ListPaginated: %v", err) - } - if len(got) != 2 { - t.Errorf("want 2 acme results, got %d: %+v", len(got), got) - } -} - -func TestAPIPortalRepo_Update(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - const orgUUID = "org-portal-upd" - createTestAPIPortalOrg(t, db, orgUUID) - - repo := NewAPIPortalRepo(db) - portal := newTestAPIPortal("portal-upd", orgUUID, "upd") - if err := repo.Create(portal); err != nil { - t.Fatalf("Create: %v", err) - } - origCreatedAt := portal.CreatedAt - - // Mutate every whitelisted field + attempt to mutate an immutable one (handle). - // OrganizationID is left untouched because the UPDATE uses it in the WHERE - // clause for org isolation; cross-org attempts are covered by - // TestAPIPortalRepo_Update_CrossOrgIsolation. - portal.Name = "Renamed" - portal.Description = "new description" - portal.URL = "https://renamed.example.com" - portal.Status = constants.APIPortalStatusActive - portal.AuthType = constants.APIPortalAuthTypeOAuth2 - portal.AuthConfig = map[string]interface{}{"stsTokenUrl": "https://sts/x"} - portal.UpdatedBy = "editor" - portal.Handle = "attempted-rename" // immutable — must NOT stick - - if err := repo.Update(portal); err != nil { - t.Fatalf("Update: %v", err) - } - - got, err := repo.GetByUUID("portal-upd", orgUUID) - if err != nil { - t.Fatalf("GetByUUID: %v", err) - } - if got == nil { - t.Fatal("row disappeared after Update") - } - if got.Name != "Renamed" || got.Description != "new description" || - got.URL != "https://renamed.example.com" || - got.Status != constants.APIPortalStatusActive || - got.AuthType != constants.APIPortalAuthTypeOAuth2 || - got.UpdatedBy != "editor" { - t.Errorf("mutable fields not persisted; got %+v", got) - } - if got.AuthConfig["stsTokenUrl"] != "https://sts/x" { - t.Errorf("configuration not persisted; got %v", got.AuthConfig) - } - if got.Handle != "upd" { - t.Errorf("handle was mutated despite being immutable; want %q, got %q", "upd", got.Handle) - } - if !got.CreatedAt.Equal(origCreatedAt) { - t.Errorf("created_at was touched; before %v, after %v", origCreatedAt, got.CreatedAt) - } -} - -func TestAPIPortalRepo_Update_NotFound(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - const orgUUID = "org-portal-upd-nf" - createTestAPIPortalOrg(t, db, orgUUID) - - repo := NewAPIPortalRepo(db) - err := repo.Update(newTestAPIPortal("ghost", orgUUID, "ghost")) - if err == nil { - t.Fatal("expected Update on missing row to error") - } - if !strings.Contains(err.Error(), "api portal not found") { - t.Errorf("want error containing %q, got %q", "api portal not found", err.Error()) - } -} - -func TestAPIPortalRepo_Update_CrossOrgIsolation(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - const orgA = "org-portal-upd-a" - const orgB = "org-portal-upd-b" - createTestAPIPortalOrg(t, db, orgA) - createTestAPIPortalOrg(t, db, orgB) - - repo := NewAPIPortalRepo(db) - if err := repo.Create(newTestAPIPortal("portal-a", orgA, "iso")); err != nil { - t.Fatalf("Create: %v", err) - } - // Attempt to update A's portal claiming to be in org B — must be rejected as not-found. - portal := newTestAPIPortal("portal-a", orgB, "iso") - portal.Name = "hijack" - err := repo.Update(portal) - if err == nil { - t.Fatal("expected Update with wrong org to error as not-found") - } - if !strings.Contains(err.Error(), "api portal not found") { - t.Errorf("want error containing %q, got %q", "api portal not found", err.Error()) - } -} - -func TestAPIPortalRepo_Delete(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - const orgUUID = "org-portal-del" - createTestAPIPortalOrg(t, db, orgUUID) - - repo := NewAPIPortalRepo(db) - portal := newTestAPIPortal("portal-del", orgUUID, "del") - if err := repo.Create(portal); err != nil { - t.Fatalf("Create: %v", err) - } - if err := repo.Delete(portal.ID, orgUUID); err != nil { - t.Fatalf("Delete: %v", err) - } - got, err := repo.GetByUUID(portal.ID, orgUUID) - if err != nil { - t.Fatalf("GetByUUID after Delete: %v", err) - } - if got != nil { - t.Errorf("row still present after Delete: %+v", got) - } -} - -func TestAPIPortalRepo_Delete_NotFound(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - const orgUUID = "org-portal-del-nf" - createTestAPIPortalOrg(t, db, orgUUID) - - repo := NewAPIPortalRepo(db) - err := repo.Delete("ghost", orgUUID) - if err == nil { - t.Fatal("expected Delete on missing row to error") - } - if !strings.Contains(err.Error(), "api portal not found") { - t.Errorf("want error containing %q, got %q", "api portal not found", err.Error()) - } -} - -func TestAPIPortalRepo_Exists(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - const orgUUID = "org-portal-exists" - createTestAPIPortalOrg(t, db, orgUUID) - - repo := NewAPIPortalRepo(db) - ok, err := repo.Exists("nope", orgUUID) - if err != nil { - t.Fatalf("Exists: %v", err) - } - if ok { - t.Error("Exists: expected false for missing row") - } - if err := repo.Create(newTestAPIPortal("portal-e", orgUUID, "here")); err != nil { - t.Fatalf("Create: %v", err) - } - ok, err = repo.Exists("here", orgUUID) - if err != nil { - t.Fatalf("Exists: %v", err) - } - if !ok { - t.Error("Exists: expected true for existing row") - } -} diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index 02c978c92e..fd4e5af755 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -326,7 +326,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, return nil, fmt.Errorf("failed to initialize secret vault: %w", vaultErr) } secretService := service.NewSecretService(secretRepo, secretVault, identityService) - apiPortalAuthRegistry := service.NewAPIPortalAuthRegistry(&cfg.Auth.JWT, secretVault, nil) + apiPortalAuthRegistry := service.NewAPIPortalAuthRegistry() apiPortalService := service.NewAPIPortalService(apiPortalRepo, orgRepo, auditRepo, secretVault, apiPortalAuthRegistry, identityService, slogger) // Initialize handlers diff --git a/platform-api/internal/service/api_portal.go b/platform-api/internal/service/api_portal.go index 3ecff9d2ea..a29f2b3a14 100644 --- a/platform-api/internal/service/api_portal.go +++ b/platform-api/internal/service/api_portal.go @@ -19,10 +19,9 @@ package service import ( "context" - "encoding/base64" - "fmt" "log/slog" "net/url" + "regexp" "strings" "github.com/google/uuid" @@ -38,15 +37,15 @@ import ( // validateAPIPortalURL enforces input-time constraints on a caller-supplied // portal URL: -// - Empty is valid — the URL is populated later by the provisioner in the -// cloud flow, and OSS may register a portal before the URL is known. +// - Empty is rejected on Create (the portal must be reachable to register it), +// see CreateAPIPortal below. // - Non-empty must parse as an absolute URL with a host, and use the https // scheme. This blocks stored SSRF via `file://`, `javascript:`, and any // plain-http URL that could be pointed at instance-metadata endpoints such // as http://169.254.169.254/. // // Deeper outbound-hardening (private-IP blocklist, DNS-rebinding checks, -// redirect controls) is intentionally NOT enforced here — it belongs in the +// redirect controls) is intentionally NOT enforced here, it belongs in the // shared outbound HTTP client the publisher will build later, so every // outbound integration gets the same protection uniformly. func validateAPIPortalURL(raw string) (string, error) { @@ -67,34 +66,30 @@ func validateAPIPortalURL(raw string) (string, error) { return u.String(), nil } -// validateAPIPortalSTSTokenURL runs the same base checks as validateAPIPortalURL -// on `authConfig.stsTokenUrl` — the target of the outbound `client_credentials` -// token request that carries clientSecret. Empty is rejected because the -// oauth2 grant needs an endpoint; a required-field check upstream also -// enforces this, but keeping it here means callers see a clear message. -// -// Host-based restrictions (loopback / private / link-local / metadata -// literals, DNS-based resolve-and-recheck) are intentionally NOT enforced -// here — a legitimate local / on-prem deployment can have its STS at -// https://localhost:9443 or a private-range address. Operator-aware egress -// controls are planned as a shared outbound HTTP client feature; the same -// deferral applies to `validateAPIPortalURL`. -func validateAPIPortalSTSTokenURL(raw string) error { +// sharedKeyPattern matches a 64-character hex string, the exact shape the +// devportal-side setup script produces via `openssl rand -hex 32` and the +// portal middleware sha256's for verification. Any other shape is rejected +// here so we never encrypt-and-store a value the portal cannot possibly match. +var sharedKeyPattern = regexp.MustCompile(`^[0-9a-fA-F]{64}$`) + +// validateAndEncryptSharedKey checks the raw sharedKey format and returns the +// AES-GCM ciphertext the row column will hold. The plaintext is discarded once +// this function returns, the only path back to it is Decrypt, which the +// outbound-auth provider does per publish call. +func validateAndEncryptSharedKey(v vault.SecretVault, raw string) ([]byte, error) { trimmed := strings.TrimSpace(raw) if trimmed == "" { - return apperror.ValidationFailed.New("The stsTokenUrl field is required.") - } - u, err := url.Parse(trimmed) - if err != nil { - return apperror.ValidationFailed.New("The stsTokenUrl field is not a valid URL.") + return nil, apperror.ValidationFailed.New("The sharedKey field is required.") } - if !u.IsAbs() || u.Host == "" { - return apperror.ValidationFailed.New("The stsTokenUrl field must be an absolute URL with a host.") + if !sharedKeyPattern.MatchString(trimmed) { + return nil, apperror.ValidationFailed.New( + "The sharedKey field must be a 64-character hex string (32 bytes of entropy, matches `openssl rand -hex 32`).") } - if u.Scheme != "https" { - return apperror.ValidationFailed.New("The stsTokenUrl field must use the https scheme.") + ciphertext, err := v.Encrypt(context.Background(), trimmed) + if err != nil { + return nil, err } - return nil + return ciphertext, nil } // APIPortalService encapsulates business logic for the /api-portals resource. @@ -142,122 +137,6 @@ func (s *APIPortalService) invalidateCachedAuthProvider(portalHandle string) { s.authRegistry.Invalidate(portalHandle) } -// validateAPIPortalAuthConfig enforces per-authType constraints on the config -// map. For `local` the map must be empty; for `oauth2` all required keys must -// be present and non-empty strings, and no unknown keys are allowed. -func validateAPIPortalAuthConfig(authType string, cfg map[string]interface{}) error { - switch authType { - case constants.APIPortalAuthTypeLocal: - if len(cfg) > 0 { - return apperror.ValidationFailed.New( - "authConfig must be empty when authType is local.") - } - return nil - case constants.APIPortalAuthTypeOAuth2: - for _, key := range constants.APIPortalOAuth2RequiredAuthConfigKeys { - v, ok := cfg[key] - if !ok { - return apperror.ValidationFailed.New( - fmt.Sprintf("authConfig field %q is required for authType %q.", key, authType)) - } - s, isString := v.(string) - if !isString || strings.TrimSpace(s) == "" { - return apperror.ValidationFailed.New( - fmt.Sprintf("authConfig field %q must be a non-empty string.", key)) - } - } - allowed := map[string]bool{ - constants.APIPortalAuthConfigKeySTSTokenURL: true, - constants.APIPortalAuthConfigKeyClientID: true, - constants.APIPortalAuthConfigKeyClientSecret: true, - } - for k := range cfg { - if !allowed[k] { - return apperror.ValidationFailed.New( - fmt.Sprintf("authConfig field %q is not supported for authType %q.", k, authType)) - } - } - // stsTokenUrl is the target of the outbound client_credentials - // request that carries clientSecret, so it gets a stricter shape - // check than a generic string. Ciphertext (already-encrypted, from - // a merge path) has never occupied this key — clientSecret is the - // only encrypted field — so the value here is a plaintext URL and - // the parse-and-check is safe. See validateAPIPortalSTSTokenURL. - if raw, ok := cfg[constants.APIPortalAuthConfigKeySTSTokenURL].(string); ok { - if err := validateAPIPortalSTSTokenURL(raw); err != nil { - return err - } - } - return nil - } - return apperror.ValidationFailed.New( - fmt.Sprintf("The authType %q is not supported.", authType)) -} - -// encryptAPIPortalAuthConfigSecrets walks the sensitive-key list and encrypts -// each key's value in place. Values are base64-encoded ciphertext strings -// after this returns. Empty / nil values are removed rather than encrypted so -// we never store an encrypted empty string. -func encryptAPIPortalAuthConfigSecrets(v vault.SecretVault, cfg map[string]interface{}) error { - if cfg == nil { - return nil - } - for _, key := range constants.APIPortalAuthConfigSensitiveKeys { - raw, ok := cfg[key] - if !ok { - continue - } - if raw == nil { - delete(cfg, key) - continue - } - plaintext, isString := raw.(string) - if !isString { - return apperror.ValidationFailed.New( - fmt.Sprintf("authConfig field %q must be a string.", key)) - } - if plaintext == "" { - delete(cfg, key) - continue - } - ciphertext, err := v.Encrypt(context.Background(), plaintext) - if err != nil { - return fmt.Errorf("failed to encrypt authConfig field %q: %w", key, err) - } - cfg[key] = base64.StdEncoding.EncodeToString(ciphertext) - } - return nil -} - -// mergeAPIPortalAuthConfig returns existing + incoming, with incoming keys -// overwriting existing ones. Used on Update so a caller can rotate a single -// field (e.g. only stsTokenUrl) without having to re-send fields they don't -// want to change — including clientSecret, which they can't fetch back. -func mergeAPIPortalAuthConfig(existing, incoming map[string]interface{}) map[string]interface{} { - merged := make(map[string]interface{}, len(existing)+len(incoming)) - for k, v := range existing { - merged[k] = v - } - for k, v := range incoming { - merged[k] = v - } - return merged -} - -// copyStringMap returns a shallow copy so the service can encrypt/mutate its -// own working set without touching the caller's map (which lives in the -// generated request DTO the handler translated). -func copyStringMap(m map[string]interface{}) map[string]interface{} { - if m == nil { - return nil - } - out := make(map[string]interface{}, len(m)) - for k, v := range m { - out[k] = v - } - return out -} - // PaginationInfo is the {total, offset, limit} triplet used to build the // list-response envelope in api_portal_translate.go. type PaginationInfo struct { @@ -275,8 +154,9 @@ func derefStr(p *string) string { } // CreateAPIPortal validates the request, enforces uniqueness of the handle, -// and inserts a new row scoped to orgID. Speaks in api-generated types -// directly so it satisfies the pdk.APIPortals contract by shape. +// encrypts the caller-supplied shared key, and inserts a new row scoped to +// orgID. Speaks in api-generated types directly so it satisfies the +// pdk.APIPortals contract by shape. func (s *APIPortalService) CreateAPIPortal(req *api.CreateApiPortalRequest, orgID, createdBy string) (*api.ApiPortalResponse, error) { if req == nil { return nil, apperror.ValidationFailed.New("The request body is required.") @@ -288,11 +168,6 @@ func (s *APIPortalService) CreateAPIPortal(req *api.CreateApiPortalRequest, orgI if err := utils.ValidateHandle(strings.TrimSpace(req.Handle)); err != nil { return nil, err } - authType := strings.TrimSpace(string(req.AuthType)) - if !constants.ValidAPIPortalAuthTypes[authType] { - return nil, apperror.ValidationFailed.New( - fmt.Sprintf("The authType %q is not supported.", authType)) - } portalURL, err := validateAPIPortalURL(req.Url) if err != nil { return nil, err @@ -300,13 +175,8 @@ func (s *APIPortalService) CreateAPIPortal(req *api.CreateApiPortalRequest, orgI if portalURL == "" { return nil, apperror.ValidationFailed.New("The url field is required.") } - // Copy the incoming authConfig so we don't mutate the caller's map when we - // encrypt secret fields in place. - authConfig := copyStringMap(authConfigStructToMap(req.AuthConfig)) - if err := validateAPIPortalAuthConfig(authType, authConfig); err != nil { - return nil, err - } - if err := encryptAPIPortalAuthConfigSecrets(s.vault, authConfig); err != nil { + encryptedKey, err := validateAndEncryptSharedKey(s.vault, derefStr(req.SharedKey)) + if err != nil { return nil, err } @@ -328,18 +198,17 @@ func (s *APIPortalService) CreateAPIPortal(req *api.CreateApiPortalRequest, orgI actor := strings.TrimSpace(createdBy) portal := &model.APIPortal{ - ID: uuid.New().String(), - OrganizationID: orgID, - Handle: strings.TrimSpace(req.Handle), - Name: name, - Description: strings.TrimSpace(derefStr(req.Description)), - URL: portalURL, - Status: constants.APIPortalStatusActive, - AuthType: authType, - AuthConfig: authConfig, - Metadata: derefAPIPortalMetadata(req.Metadata), - CreatedBy: actor, - UpdatedBy: actor, + ID: uuid.New().String(), + OrganizationID: orgID, + Handle: strings.TrimSpace(req.Handle), + Name: name, + Description: strings.TrimSpace(derefStr(req.Description)), + URL: portalURL, + Status: constants.APIPortalStatusActive, + InternalAuthKey: encryptedKey, + Metadata: derefAPIPortalMetadata(req.Metadata), + CreatedBy: actor, + UpdatedBy: actor, } if err := s.portalRepo.Create(portal); err != nil { @@ -368,7 +237,7 @@ func (s *APIPortalService) GetAPIPortal(handle, orgID string) (*api.ApiPortalRes // ListAPIPortals returns a page of API Portals in the organization, honoring // the requested pagination + filter args. Limit/Offset are normalized here. // Flat args (rather than an options struct) so the method satisfies the -// pdk.APIPortals contract by shape — matches the Gateways pattern. +// pdk.APIPortals contract by shape, matches the Gateways pattern. func (s *APIPortalService) ListAPIPortals(orgID string, limit, offset int, sortBy, sortOrder, search string) (*api.ApiPortalListResponse, error) { org, err := s.orgRepo.GetOrganizationByUUID(orgID) if err != nil { @@ -407,6 +276,12 @@ func (s *APIPortalService) ListAPIPortals(orgID string, limit, offset int, sortB // UpdateAPIPortal loads the row, applies only the whitelisted mutations from // req, persists the change, and returns the updated row. Nil pointer fields // on the request mean "not sent" and are passed through unchanged. +// +// sharedKey is the rotation path: when the caller supplies a new hex value on +// the wire, we replace the encrypted stored value with a fresh encryption of +// the new plaintext. When sharedKey is absent, the stored bytes are left as-is, +// this matches the "supply only the fields you want to change" contract for +// every other field on Update. func (s *APIPortalService) UpdateAPIPortal(handle string, req *api.UpdateApiPortalRequest, orgID, updatedBy string) (*api.ApiPortalResponse, error) { if req == nil { return nil, apperror.ValidationFailed.New("The request body is required.") @@ -439,40 +314,16 @@ func (s *APIPortalService) UpdateAPIPortal(handle string, req *api.UpdateApiPort } portal.URL = portalURL } - if req.AuthType != nil { - at := strings.TrimSpace(string(*req.AuthType)) - if !constants.ValidAPIPortalAuthTypes[at] { - return nil, apperror.ValidationFailed.New( - fmt.Sprintf("The authType %q is not supported.", at)) - } - portal.AuthType = at - } - if req.AuthConfig != nil { - // Merge into the stored authConfig — supplied keys overwrite existing, - // missing keys are retained. Encrypt any newly supplied sensitive - // fields before persistence; existing encrypted values pass through - // untouched because their key isn't in the incoming map. - incoming := copyStringMap(authConfigStructToMap(req.AuthConfig)) - if err := encryptAPIPortalAuthConfigSecrets(s.vault, incoming); err != nil { + if req.SharedKey != nil { + encryptedKey, err := validateAndEncryptSharedKey(s.vault, *req.SharedKey) + if err != nil { return nil, err } - portal.AuthConfig = mergeAPIPortalAuthConfig(portal.AuthConfig, incoming) + portal.InternalAuthKey = encryptedKey } if req.Metadata != nil { // Metadata is opaque pass-through; supplied map fully replaces stored. - portal.Metadata = copyStringMap(derefAPIPortalMetadata(req.Metadata)) - } - // authType owns the shape of authConfig. When the effective type is `local`, - // authConfig keys carried over from a previous `oauth2` configuration are - // dropped rather than left to fail a validation the caller cannot satisfy - // (they can't send authConfig=null on the wire to clear it while nil-vs- - // absent are the same shape in JSON). - if portal.AuthType == constants.APIPortalAuthTypeLocal { - portal.AuthConfig = nil - } - // Re-validate authConfig against the effective authType after all mutations. - if err := validateAPIPortalAuthConfig(portal.AuthType, portal.AuthConfig); err != nil { - return nil, err + portal.Metadata = derefAPIPortalMetadata(req.Metadata) } portal.UpdatedBy = strings.TrimSpace(updatedBy) diff --git a/platform-api/internal/service/api_portal_auth.go b/platform-api/internal/service/api_portal_auth.go index 6e0f8e8ee2..c2e7dd8e7c 100644 --- a/platform-api/internal/service/api_portal_auth.go +++ b/platform-api/internal/service/api_portal_auth.go @@ -15,319 +15,82 @@ * */ -// This file wires the outbound authentication path Platform-API uses when it -// calls a portal's admin REST endpoints. Two provider implementations sit -// behind one interface, and a small process-wide registry caches provider -// instances (and their token caches) per portal handle. +// Placeholder for the shared-key outbound authentication path. // -// Consumers (the publisher and any future portal-facing component) call -// APIPortalAuthRegistry.Get(portal).AuthorizationHeader(ctx) and get back a -// ready-to-use `Bearer ` string. Token refresh, caching, and mutex- -// guarded refresh are the provider's concern, not the caller's. +// The CRUD surface for /api-portals now stores an encrypted shared key on each +// row (model.APIPortal.InternalAuthKey). The provider that decrypts that value +// and returns `Authorization: SharedKey ` for every publish call is a +// self-contained subsystem tracked as follow-up work, this file keeps only the +// interface and the registry hook the service and its callers already speak +// against, so the branch stays compilable while the implementation lands +// separately. +// +// See the design doc: Projects/DevPortal Publishing/Platform-API-Devportal-Design/ +// SharedKey-Auth-Design.md (§Platform-API side). package service import ( "context" - "encoding/base64" - "encoding/json" "errors" - "fmt" - "io" - "net/http" - "net/url" - "strings" "sync" - "time" - - "github.com/golang-jwt/jwt/v5" - - "github.com/wso2/api-platform/platform-api/config" - "github.com/wso2/api-platform/platform-api/internal/constants" - "github.com/wso2/api-platform/platform-api/internal/model" - "github.com/wso2/api-platform/platform-api/internal/vault" ) // AuthProvider is the outbound-auth surface exposed to any component that -// needs to call a portal's admin REST endpoints. The concrete provider -// (`local` or `oauth2`) is selected per portal by the registry; callers are -// unaware of which one they're holding. +// needs to call a portal's admin REST endpoints. type AuthProvider interface { - // AuthorizationHeader returns a valid "Bearer " header value for - // the next outbound call. The provider handles all caching + refresh - // internally — callers never see a stale token unless they invalidate - // explicitly. + // AuthorizationHeader returns the "SharedKey " header value for the + // next outbound call. Concrete implementations decrypt the row's stored + // key (OSS) or resolve it out of a secret backend (cloud) and cache the + // plaintext in memory. AuthorizationHeader(ctx context.Context) (string, error) - // InvalidateCache clears any cached token so the next call re-mints or - // re-fetches. Callers invoke this on a portal-side 401 to recover from - // an expired/revoked token the provider hasn't yet noticed. + // InvalidateCache clears any cached plaintext so the next call re-reads + // from source. Callers invoke this after the caller-visible row updates. InvalidateCache() } -// tokenCacheRefreshBuffer is the safety window subtracted from the STS-reported -// expiry so we refresh slightly before the token actually expires — otherwise -// a call that lands right at the expiry boundary would fail with a 401. -const tokenCacheRefreshBuffer = 30 * time.Second - -// localTokenTTL is the lifetime of a self-minted JWT for the `local` flow. -// Kept short so any key/config change on disk is picked up quickly on the -// next refresh; minting is cheap (single RS256 sign). -const localTokenTTL = 5 * time.Minute - -// --- LocalAuthProvider ----------------------------------------------------- - -// localAuthProvider mints platform-api-signed RS256 JWTs. The signing key is -// the same one AuthLoginHandler uses (Auth.JWT.PrivateKeyFile), so the -// devportal's `verifyBearerToken` in local mode — configured with the paired -// public key — accepts these tokens without any per-portal setup. -type localAuthProvider struct { - jwtCfg *config.JWT - - mu sync.Mutex - cached string - expiresAt time.Time -} - -func newLocalAuthProvider(jwtCfg *config.JWT) *localAuthProvider { - return &localAuthProvider{jwtCfg: jwtCfg} -} - -func (p *localAuthProvider) AuthorizationHeader(_ context.Context) (string, error) { - p.mu.Lock() - defer p.mu.Unlock() - if p.cached != "" && time.Now().Before(p.expiresAt.Add(-tokenCacheRefreshBuffer)) { - return p.cached, nil - } - - priv, err := p.jwtCfg.LoadPrivateKey() - if err != nil { - return "", fmt.Errorf("api-portal local auth: load private key: %w", err) - } - now := time.Now() - exp := now.Add(localTokenTTL) - claims := jwt.MapClaims{ - "sub": "platform-api-system", - "iss": "platform-api", - "roles": []string{"platform-api-system"}, - "iat": now.Unix(), - "exp": exp.Unix(), - } - signed, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(priv) - if err != nil { - return "", fmt.Errorf("api-portal local auth: sign token: %w", err) - } - p.cached = "Bearer " + signed - p.expiresAt = exp - return p.cached, nil -} - -func (p *localAuthProvider) InvalidateCache() { - p.mu.Lock() - defer p.mu.Unlock() - p.cached = "" - p.expiresAt = time.Time{} -} - -// --- ClientCredentialsAuthProvider ----------------------------------------- - -// clientCredentialsTokenResponse is the RFC 6749 §5.1 successful token -// response shape. Providers may include additional fields; we only read the -// two we need. -type clientCredentialsTokenResponse struct { - AccessToken string `json:"access_token"` - ExpiresIn int `json:"expires_in"` -} - -// clientCredentialsAuthProvider fetches a JWT from an external STS using the -// OAuth 2.0 client_credentials grant. Holds the plaintext client secret in -// memory for the lifetime of the provider; the caller retrieves that value by -// decrypting the stored ciphertext via the vault before constructing this. -type clientCredentialsAuthProvider struct { - tokenURL string - clientID string - clientSecret string - httpClient *http.Client - +// APIPortalAuthRegistry keeps at most one AuthProvider per portal handle. The +// service's Update / Delete paths call Invalidate so the next outbound call +// picks up whatever the row now says. Concrete provider construction is the +// caller's problem, this type only manages the cache. +type APIPortalAuthRegistry struct { mu sync.Mutex - cached string - expiresAt time.Time + providers map[string]AuthProvider } -func newClientCredentialsAuthProvider(tokenURL, clientID, clientSecret string, hc *http.Client) *clientCredentialsAuthProvider { - // Always REJECT redirects on the token-endpoint call. A 3xx from the STS - // on this endpoint isn't a legitimate part of the client-credentials - // flow — following it would re-send the client_id + client_secret to a - // redirect target chosen by whatever answered. This is enforced - // regardless of what a caller-supplied client had configured; we copy - // the caller's *http.Client so their instance keeps its own policy for - // any other use. - var client *http.Client - if hc == nil { - client = &http.Client{Timeout: 15 * time.Second} - } else { - copied := *hc - client = &copied - } - client.CheckRedirect = func(*http.Request, []*http.Request) error { - return http.ErrUseLastResponse - } - return &clientCredentialsAuthProvider{ - tokenURL: tokenURL, - clientID: clientID, - clientSecret: clientSecret, - httpClient: client, - } +// NewAPIPortalAuthRegistry constructs an empty registry. +func NewAPIPortalAuthRegistry() *APIPortalAuthRegistry { + return &APIPortalAuthRegistry{providers: map[string]AuthProvider{}} } -func (p *clientCredentialsAuthProvider) AuthorizationHeader(ctx context.Context) (string, error) { - p.mu.Lock() - defer p.mu.Unlock() - if p.cached != "" && time.Now().Before(p.expiresAt.Add(-tokenCacheRefreshBuffer)) { - return p.cached, nil - } - - form := url.Values{} - form.Set("grant_type", "client_credentials") - form.Set("client_id", p.clientID) - form.Set("client_secret", p.clientSecret) - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.tokenURL, strings.NewReader(form.Encode())) - if err != nil { - return "", fmt.Errorf("api-portal oauth2 auth: build request: %w", err) - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Accept", "application/json") - - resp, err := p.httpClient.Do(req) - if err != nil { - return "", fmt.Errorf("api-portal oauth2 auth: token request: %w", err) - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", fmt.Errorf("api-portal oauth2 auth: sts returned %d: %s", resp.StatusCode, string(body)) - } - var tok clientCredentialsTokenResponse - if err := json.Unmarshal(body, &tok); err != nil { - return "", fmt.Errorf("api-portal oauth2 auth: decode token response: %w", err) - } - if tok.AccessToken == "" { - return "", errors.New("api-portal oauth2 auth: sts response missing access_token") - } - // If expires_in is zero or missing, treat the token as short-lived so we - // refresh soon rather than caching for an unknown duration. - ttl := time.Duration(tok.ExpiresIn) * time.Second - if ttl <= 0 { - ttl = time.Minute +// Invalidate drops the cached provider for a portal handle. No-op when the +// handle has no cached entry (idempotent, safe to call from Delete paths). +func (r *APIPortalAuthRegistry) Invalidate(portalHandle string) { + if r == nil { + return } - p.cached = "Bearer " + tok.AccessToken - p.expiresAt = time.Now().Add(ttl) - return p.cached, nil -} - -func (p *clientCredentialsAuthProvider) InvalidateCache() { - p.mu.Lock() - defer p.mu.Unlock() - p.cached = "" - p.expiresAt = time.Time{} -} - -// --- APIPortalAuthRegistry -------------------------------------------------- - -// APIPortalAuthRegistry is the process-wide cache of AuthProvider instances -// keyed by portal handle. Callers (publisher, health-check, anything else -// talking to a portal admin REST) share these instances so their token -// caches are hot across concurrent requests. Invalidate is called by the -// service layer on Update/Delete so cached providers reflect config changes. -type APIPortalAuthRegistry struct { - jwtCfg *config.JWT - secrets vault.SecretVault - httpClient *http.Client - - mu sync.Mutex - cache map[string]AuthProvider + r.mu.Lock() + defer r.mu.Unlock() + delete(r.providers, portalHandle) } -// NewAPIPortalAuthRegistry constructs the registry. `hc` may be nil, in which -// case each oauth2 provider gets a default http.Client with a 15s timeout. -func NewAPIPortalAuthRegistry(jwtCfg *config.JWT, secretVault vault.SecretVault, hc *http.Client) *APIPortalAuthRegistry { - return &APIPortalAuthRegistry{ - jwtCfg: jwtCfg, - secrets: secretVault, - httpClient: hc, - cache: make(map[string]AuthProvider), - } -} +// errSharedKeyProviderNotImplemented is returned by Get until the SharedKey +// provider implementation lands. Removed once the follow-up PR wires the real +// provider through here. +var errSharedKeyProviderNotImplemented = errors.New("shared-key AuthProvider not yet implemented; see SharedKey-Auth-Design.md") -// Get returns the cached AuthProvider for a portal, constructing one from the -// stored row if none exists yet. Never returns a nil provider on success. -func (r *APIPortalAuthRegistry) Get(portal *model.APIPortal) (AuthProvider, error) { - if portal == nil { - return nil, errors.New("api-portal auth registry: portal is nil") +// Get returns a cached AuthProvider for the portal handle, or an error when +// none is configured. The provider construction path is deferred to the +// follow-up SharedKey work, callers today only need Invalidate to be safe. +func (r *APIPortalAuthRegistry) Get(portalHandle string) (AuthProvider, error) { + if r == nil { + return nil, errSharedKeyProviderNotImplemented } r.mu.Lock() defer r.mu.Unlock() - if p, ok := r.cache[portal.Handle]; ok { + if p, ok := r.providers[portalHandle]; ok { return p, nil } - p, err := r.buildProvider(portal) - if err != nil { - return nil, err - } - r.cache[portal.Handle] = p - return p, nil -} - -// Invalidate evicts the cached provider for the given portal handle. Called by -// the service layer after a successful Update or Delete so the next Get -// picks up any config changes (or, for Delete, so we don't leak stale -// providers). -func (r *APIPortalAuthRegistry) Invalidate(portalHandle string) { - r.mu.Lock() - defer r.mu.Unlock() - delete(r.cache, portalHandle) -} - -// buildProvider constructs the concrete provider from a stored portal row. -// For oauth2 the clientSecret is base64-decoded and decrypted via the vault -// here; the plaintext is then held in memory by the provider until the -// registry entry is invalidated. -func (r *APIPortalAuthRegistry) buildProvider(portal *model.APIPortal) (AuthProvider, error) { - switch portal.AuthType { - case constants.APIPortalAuthTypeLocal: - if r.jwtCfg == nil { - return nil, errors.New("api-portal auth registry: jwtCfg is nil; cannot mint local tokens") - } - return newLocalAuthProvider(r.jwtCfg), nil - case constants.APIPortalAuthTypeOAuth2: - if r.secrets == nil { - return nil, errors.New("api-portal auth registry: secret vault is nil; cannot decrypt oauth2 client secret") - } - tokenURL, _ := portal.AuthConfig[constants.APIPortalAuthConfigKeySTSTokenURL].(string) - clientID, _ := portal.AuthConfig[constants.APIPortalAuthConfigKeyClientID].(string) - encoded, _ := portal.AuthConfig[constants.APIPortalAuthConfigKeyClientSecret].(string) - if tokenURL == "" || clientID == "" || encoded == "" { - return nil, fmt.Errorf( - "api-portal auth registry: portal %q authConfig is missing required oauth2 fields", - portal.Handle) - } - ciphertext, err := base64.StdEncoding.DecodeString(encoded) - if err != nil { - return nil, fmt.Errorf( - "api-portal auth registry: portal %q clientSecret is not valid base64 ciphertext: %w", - portal.Handle, err) - } - plaintext, err := r.secrets.Decrypt(context.Background(), ciphertext) - if err != nil { - return nil, fmt.Errorf( - "api-portal auth registry: portal %q clientSecret decryption failed: %w", - portal.Handle, err) - } - return newClientCredentialsAuthProvider(tokenURL, clientID, plaintext, r.httpClient), nil - default: - return nil, fmt.Errorf( - "api-portal auth registry: portal %q has unsupported auth_type %q", - portal.Handle, portal.AuthType) - } + return nil, errSharedKeyProviderNotImplemented } diff --git a/platform-api/internal/service/api_portal_auth_test.go b/platform-api/internal/service/api_portal_auth_test.go deleted file mode 100644 index 5eae3cc188..0000000000 --- a/platform-api/internal/service/api_portal_auth_test.go +++ /dev/null @@ -1,415 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. - * - * Licensed 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 service - -import ( - "context" - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "encoding/base64" - "encoding/json" - "encoding/pem" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/golang-jwt/jwt/v5" - - "github.com/wso2/api-platform/platform-api/config" - "github.com/wso2/api-platform/platform-api/internal/constants" - "github.com/wso2/api-platform/platform-api/internal/model" -) - -// --- test helpers ------------------------------------------------------------ - -// newTestJWTConfig writes a fresh RSA private key to t.TempDir and returns a -// config.JWT pointing at it. Each test gets its own key so parallel runs stay -// isolated. -func newTestJWTConfig(t *testing.T) (*config.JWT, *rsa.PublicKey) { - t.Helper() - priv, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - t.Fatalf("generate rsa: %v", err) - } - der, err := x509.MarshalPKCS8PrivateKey(priv) - if err != nil { - t.Fatalf("marshal pkcs8: %v", err) - } - pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) - path := filepath.Join(t.TempDir(), "signing.pem") - if err := os.WriteFile(path, pemBytes, 0o600); err != nil { - t.Fatalf("write pem: %v", err) - } - return &config.JWT{PrivateKeyFile: path}, &priv.PublicKey -} - -// --- LocalAuthProvider tests ------------------------------------------------ - -func TestLocalAuthProvider_MintsVerifiableRS256(t *testing.T) { - jwtCfg, pub := newTestJWTConfig(t) - p := newLocalAuthProvider(jwtCfg) - - header, err := p.AuthorizationHeader(context.Background()) - if err != nil { - t.Fatalf("AuthorizationHeader: %v", err) - } - if !strings.HasPrefix(header, "Bearer ") { - t.Fatalf("expected Bearer prefix, got %q", header) - } - raw := strings.TrimPrefix(header, "Bearer ") - - parsed, err := jwt.Parse(raw, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { - t.Fatalf("unexpected signing method: %v", token.Method) - } - return pub, nil - }) - if err != nil || !parsed.Valid { - t.Fatalf("token failed to verify: %v (valid=%v)", err, parsed != nil && parsed.Valid) - } - claims := parsed.Claims.(jwt.MapClaims) - if claims["sub"] != "platform-api-system" { - t.Errorf("sub: got %v", claims["sub"]) - } - if claims["iss"] != "platform-api" { - t.Errorf("iss: got %v", claims["iss"]) - } - rolesIface, ok := claims["roles"].([]interface{}) - if !ok || len(rolesIface) != 1 || rolesIface[0] != "platform-api-system" { - t.Errorf("roles claim: got %v", claims["roles"]) - } -} - -func TestLocalAuthProvider_CachesToken(t *testing.T) { - jwtCfg, _ := newTestJWTConfig(t) - p := newLocalAuthProvider(jwtCfg) - - h1, err := p.AuthorizationHeader(context.Background()) - if err != nil { - t.Fatalf("first mint: %v", err) - } - h2, err := p.AuthorizationHeader(context.Background()) - if err != nil { - t.Fatalf("second mint: %v", err) - } - if h1 != h2 { - t.Errorf("expected cached token to be reused; got %q vs %q", h1, h2) - } -} - -func TestLocalAuthProvider_InvalidateForcesRefresh(t *testing.T) { - jwtCfg, _ := newTestJWTConfig(t) - p := newLocalAuthProvider(jwtCfg) - - h1, err := p.AuthorizationHeader(context.Background()) - if err != nil { - t.Fatalf("first mint: %v", err) - } - // Sleep a moment so iat/exp claims differ between mints; RS256 signing is - // deterministic for identical inputs, so a same-second re-mint would - // produce the same signature and defeat the assertion. - time.Sleep(time.Second + 100*time.Millisecond) - p.InvalidateCache() - h2, err := p.AuthorizationHeader(context.Background()) - if err != nil { - t.Fatalf("post-invalidate mint: %v", err) - } - if h1 == h2 { - t.Errorf("expected fresh token after Invalidate; got same value") - } -} - -// --- ClientCredentialsAuthProvider tests ------------------------------------ - -// stsStub is a minimal STS token endpoint used to verify the request body and -// return a canned token response. Counts requests so tests can assert caching -// behaviour. -type stsStub struct { - server *httptest.Server - calls int32 - nextToken string - nextTTL int - nextStatus int - lastForm string -} - -func newSTSStub() *stsStub { - s := &stsStub{nextToken: "tok-1", nextTTL: 3600, nextStatus: 200} - s.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt32(&s.calls, 1) - if err := r.ParseForm(); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - s.lastForm = r.Form.Encode() - if s.nextStatus < 200 || s.nextStatus >= 300 { - w.WriteHeader(s.nextStatus) - _, _ = w.Write([]byte(`{"error":"invalid_client"}`)) - return - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(clientCredentialsTokenResponse{ - AccessToken: s.nextToken, - ExpiresIn: s.nextTTL, - }) - })) - return s -} - -func (s *stsStub) URL() string { return s.server.URL } -func (s *stsStub) Calls() int { return int(atomic.LoadInt32(&s.calls)) } -func (s *stsStub) Close() { s.server.Close() } - -func TestClientCredentialsAuthProvider_FetchesAndFormatsHeader(t *testing.T) { - sts := newSTSStub() - t.Cleanup(sts.Close) - sts.nextToken = "abc.def.ghi" - - p := newClientCredentialsAuthProvider(sts.URL(), "client-id", "client-secret", nil) - header, err := p.AuthorizationHeader(context.Background()) - if err != nil { - t.Fatalf("AuthorizationHeader: %v", err) - } - if header != "Bearer abc.def.ghi" { - t.Errorf("header: got %q, want %q", header, "Bearer abc.def.ghi") - } - // Assert the request body carries the expected grant params. - if !strings.Contains(sts.lastForm, "grant_type=client_credentials") || - !strings.Contains(sts.lastForm, "client_id=client-id") || - !strings.Contains(sts.lastForm, "client_secret=client-secret") { - t.Errorf("STS form fields wrong: %q", sts.lastForm) - } -} - -func TestClientCredentialsAuthProvider_CachesUntilNearExpiry(t *testing.T) { - sts := newSTSStub() - t.Cleanup(sts.Close) - - p := newClientCredentialsAuthProvider(sts.URL(), "id", "secret", nil) - for i := 0; i < 3; i++ { - if _, err := p.AuthorizationHeader(context.Background()); err != nil { - t.Fatalf("call %d: %v", i, err) - } - } - if sts.Calls() != 1 { - t.Errorf("expected 1 STS call (cache hit for the rest); got %d", sts.Calls()) - } -} - -func TestClientCredentialsAuthProvider_InvalidateForcesRefetch(t *testing.T) { - sts := newSTSStub() - t.Cleanup(sts.Close) - - p := newClientCredentialsAuthProvider(sts.URL(), "id", "secret", nil) - if _, err := p.AuthorizationHeader(context.Background()); err != nil { - t.Fatal(err) - } - p.InvalidateCache() - if _, err := p.AuthorizationHeader(context.Background()); err != nil { - t.Fatal(err) - } - if sts.Calls() != 2 { - t.Errorf("expected 2 STS calls after invalidate; got %d", sts.Calls()) - } -} - -func TestClientCredentialsAuthProvider_NonSuccessStatusReturnsError(t *testing.T) { - sts := newSTSStub() - t.Cleanup(sts.Close) - sts.nextStatus = 401 - - p := newClientCredentialsAuthProvider(sts.URL(), "id", "wrong-secret", nil) - _, err := p.AuthorizationHeader(context.Background()) - if err == nil { - t.Fatal("expected error for 401 from STS") - } - if !strings.Contains(err.Error(), "401") { - t.Errorf("error should surface the status code; got %v", err) - } -} - -func TestClientCredentialsAuthProvider_RefusesRedirects(t *testing.T) { - // A 3xx returned by the STS on the token endpoint isn't a legitimate - // part of the client-credentials flow; following it would re-send - // client_id + client_secret to whatever host the redirect names. We - // treat the 3xx as a non-2xx and surface an error. Enforced on the - // default *http.Client the provider builds, AND on a client the caller - // supplies — so a test / callsite that hands in its own client can't - // accidentally opt out. - redirecting := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "https://elsewhere.example.com/oauth2/token", http.StatusFound) - })) - t.Cleanup(redirecting.Close) - - // Nil hc → provider builds its own default. Should refuse. - pDefault := newClientCredentialsAuthProvider(redirecting.URL, "id", "secret", nil) - if _, err := pDefault.AuthorizationHeader(context.Background()); err == nil || - !strings.Contains(err.Error(), "302") { - t.Fatalf("default client should refuse redirect; got err=%v", err) - } - - // Caller-supplied hc that WOULD follow redirects by default. Provider - // must still refuse — meaning its own copy has CheckRedirect wired, - // and the caller's original client remains untouched. - callerClient := &http.Client{Timeout: 5 * time.Second} - pCaller := newClientCredentialsAuthProvider(redirecting.URL, "id", "secret", callerClient) - if _, err := pCaller.AuthorizationHeader(context.Background()); err == nil || - !strings.Contains(err.Error(), "302") { - t.Errorf("caller-supplied client should also refuse redirect; got err=%v", err) - } - if callerClient.CheckRedirect != nil { - t.Error("caller's original *http.Client was mutated; expected the provider to copy it") - } -} - -func TestClientCredentialsAuthProvider_ConcurrentCallsIssueSingleFetch(t *testing.T) { - sts := newSTSStub() - t.Cleanup(sts.Close) - - p := newClientCredentialsAuthProvider(sts.URL(), "id", "secret", nil) - var wg sync.WaitGroup - for i := 0; i < 10; i++ { - wg.Add(1) - go func() { - defer wg.Done() - _, _ = p.AuthorizationHeader(context.Background()) - }() - } - wg.Wait() - if sts.Calls() != 1 { - t.Errorf("thundering-herd guard: expected 1 STS call, got %d", sts.Calls()) - } -} - -// --- APIPortalAuthRegistry tests -------------------------------------------- - -func TestAPIPortalAuthRegistry_LocalRoundTrip(t *testing.T) { - jwtCfg, _ := newTestJWTConfig(t) - reg := NewAPIPortalAuthRegistry(jwtCfg, newTestVault(t), nil) - portal := &model.APIPortal{Handle: "acme", AuthType: constants.APIPortalAuthTypeLocal} - p, err := reg.Get(portal) - if err != nil { - t.Fatalf("Get: %v", err) - } - if _, err := p.AuthorizationHeader(context.Background()); err != nil { - t.Fatalf("AuthorizationHeader: %v", err) - } -} - -func TestAPIPortalAuthRegistry_OAuth2DecryptsSecret(t *testing.T) { - v := newTestVault(t) - sts := newSTSStub() - t.Cleanup(sts.Close) - - // Encrypt the plaintext secret the same way the service does on write. - ciphertext, err := v.Encrypt(context.Background(), "s3cr3t") - if err != nil { - t.Fatalf("encrypt: %v", err) - } - encoded := base64.StdEncoding.EncodeToString(ciphertext) - - reg := NewAPIPortalAuthRegistry(nil, v, nil) - portal := &model.APIPortal{ - Handle: "acme", - AuthType: constants.APIPortalAuthTypeOAuth2, - AuthConfig: map[string]interface{}{ - constants.APIPortalAuthConfigKeySTSTokenURL: sts.URL(), - constants.APIPortalAuthConfigKeyClientID: "cid", - constants.APIPortalAuthConfigKeyClientSecret: encoded, - }, - } - p, err := reg.Get(portal) - if err != nil { - t.Fatalf("Get: %v", err) - } - if _, err := p.AuthorizationHeader(context.Background()); err != nil { - t.Fatalf("AuthorizationHeader: %v", err) - } - // The provider should have sent the decrypted plaintext to the STS. - if !strings.Contains(sts.lastForm, "client_secret=s3cr3t") { - t.Errorf("provider did not decrypt clientSecret before sending; STS form: %q", sts.lastForm) - } -} - -func TestAPIPortalAuthRegistry_GetReturnsSameInstance(t *testing.T) { - jwtCfg, _ := newTestJWTConfig(t) - reg := NewAPIPortalAuthRegistry(jwtCfg, newTestVault(t), nil) - portal := &model.APIPortal{Handle: "acme", AuthType: constants.APIPortalAuthTypeLocal} - a, err := reg.Get(portal) - if err != nil { - t.Fatal(err) - } - b, err := reg.Get(portal) - if err != nil { - t.Fatal(err) - } - if a != b { - t.Errorf("expected same cached instance across calls") - } -} - -func TestAPIPortalAuthRegistry_InvalidateEvicts(t *testing.T) { - jwtCfg, _ := newTestJWTConfig(t) - reg := NewAPIPortalAuthRegistry(jwtCfg, newTestVault(t), nil) - portal := &model.APIPortal{Handle: "acme", AuthType: constants.APIPortalAuthTypeLocal} - a, _ := reg.Get(portal) - reg.Invalidate("acme") - b, _ := reg.Get(portal) - if a == b { - t.Errorf("expected a fresh instance after Invalidate") - } -} - -func TestAPIPortalAuthRegistry_OAuth2MissingFieldsFails(t *testing.T) { - reg := NewAPIPortalAuthRegistry(nil, newTestVault(t), nil) - portal := &model.APIPortal{ - Handle: "acme", - AuthType: constants.APIPortalAuthTypeOAuth2, - AuthConfig: map[string]interface{}{ - // stsTokenUrl missing. - constants.APIPortalAuthConfigKeyClientID: "cid", - constants.APIPortalAuthConfigKeyClientSecret: "not-really-encrypted", - }, - } - if _, err := reg.Get(portal); err == nil { - t.Fatal("expected error for missing oauth2 authConfig fields") - } -} - -func TestAPIPortalAuthRegistry_OAuth2BadCiphertextFails(t *testing.T) { - reg := NewAPIPortalAuthRegistry(nil, newTestVault(t), nil) - portal := &model.APIPortal{ - Handle: "acme", - AuthType: constants.APIPortalAuthTypeOAuth2, - AuthConfig: map[string]interface{}{ - constants.APIPortalAuthConfigKeySTSTokenURL: "https://sts", - constants.APIPortalAuthConfigKeyClientID: "cid", - constants.APIPortalAuthConfigKeyClientSecret: "!!!not-base64!!!", - }, - } - if _, err := reg.Get(portal); err == nil { - t.Fatal("expected error for non-base64 clientSecret") - } -} diff --git a/platform-api/internal/service/api_portal_test.go b/platform-api/internal/service/api_portal_test.go deleted file mode 100644 index 111773622e..0000000000 --- a/platform-api/internal/service/api_portal_test.go +++ /dev/null @@ -1,740 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. - * - * Licensed 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 service - -import ( - "bytes" - "errors" - "testing" - - "github.com/wso2/api-platform/platform-api/api" - "github.com/wso2/api-platform/platform-api/internal/apperror" - "github.com/wso2/api-platform/platform-api/internal/constants" - "github.com/wso2/api-platform/platform-api/internal/model" - "github.com/wso2/api-platform/platform-api/internal/repository" - "github.com/wso2/api-platform/platform-api/internal/vault" -) - -// newTestVault returns a real InHouseVault seeded with a deterministic 32-byte -// key. Using the real implementation (rather than a fake) validates the -// encrypt/decrypt round-trip actually works. -func newTestVault(t *testing.T) vault.SecretVault { - t.Helper() - v, err := vault.NewInHouseVault(bytes.Repeat([]byte("t"), 32)) - if err != nil { - t.Fatalf("test vault: %v", err) - } - return v -} - -// --- mocks --- -// Each mock embeds the interface so unimplemented methods panic on invocation, -// making it obvious when a test exercises an unstubbed code path. - -type mockAPIPortalRepository struct { - repository.APIPortalRepository - - existsResult bool - existsErr error - - createErr error - createReturnUnique bool // if true, Create returns a canned unique-violation - createCapturedInput *model.APIPortal - - getResult *model.APIPortal - getErr error - - listResult []*model.APIPortal - listErr error - - countResult int - countErr error - - updateErr error - updateCapturedInput *model.APIPortal - - deleteCalledWith [2]string - deleteErr error -} - -// canned unique-violation error — matches IsUniqueViolation's SQLite substring. -var errCannedUnique = errors.New("UNIQUE constraint failed: api_portals.handle") - -func (m *mockAPIPortalRepository) Exists(handle, orgUUID string) (bool, error) { - return m.existsResult, m.existsErr -} - -func (m *mockAPIPortalRepository) Create(portal *model.APIPortal) error { - m.createCapturedInput = portal - if m.createReturnUnique { - return errCannedUnique - } - return m.createErr -} - -func (m *mockAPIPortalRepository) GetByHandleAndOrgID(handle, orgUUID string) (*model.APIPortal, error) { - return m.getResult, m.getErr -} - -func (m *mockAPIPortalRepository) ListPaginated(orgUUID string, opts repository.ListOptions) ([]*model.APIPortal, error) { - return m.listResult, m.listErr -} - -func (m *mockAPIPortalRepository) Count(orgUUID string, search string) (int, error) { - return m.countResult, m.countErr -} - -func (m *mockAPIPortalRepository) Update(portal *model.APIPortal) error { - m.updateCapturedInput = portal - return m.updateErr -} - -func (m *mockAPIPortalRepository) Delete(portalID, orgUUID string) error { - m.deleteCalledWith = [2]string{portalID, orgUUID} - return m.deleteErr -} - -type mockAPIPortalOrgRepository struct { - repository.OrganizationRepository - result *model.Organization - err error -} - -func (m *mockAPIPortalOrgRepository) GetOrganizationByUUID(uuid string) (*model.Organization, error) { - return m.result, m.err -} - -type mockAPIPortalAuditRepository struct { - repository.AuditRepository - records []auditRecord -} - -type auditRecord struct { - action string - resourceUUID string - resourceType string - orgUUID string - performedBy string -} - -func (m *mockAPIPortalAuditRepository) Record(action, resourceUUID, resourceType, orgUUID, performedBy string) error { - m.records = append(m.records, auditRecord{action, resourceUUID, resourceType, orgUUID, performedBy}) - return nil -} - -// newTestAPIPortalService wires the three mocks together with a real -// InHouseVault. identity + slogger are nil because the service does not invoke -// them. -func newTestAPIPortalService(t *testing.T, - portalRepo repository.APIPortalRepository, - orgRepo repository.OrganizationRepository, - auditRepo repository.AuditRepository, -) *APIPortalService { - return NewAPIPortalService(portalRepo, orgRepo, auditRepo, newTestVault(t), nil, nil, nil) -} - -func apiPortalStrPtr(s string) *string { return &s } - -// --- test-DTO builders --- -// -// Kept next to the tests they serve — construct api-generated request DTOs -// from the flat fields older tests used, so migrations from the previous -// service-private request struct stayed small. Doesn't test anything itself. - -type testCreateReq struct { - Handle string - Name string - Description string - URL string - AuthType string - AuthConfig map[string]interface{} - Metadata map[string]interface{} -} - -func (r testCreateReq) build() *api.CreateApiPortalRequest { - out := &api.CreateApiPortalRequest{ - Handle: r.Handle, - Name: r.Name, - Url: r.URL, - AuthType: api.CreateApiPortalRequestAuthType(r.AuthType), - } - if r.Description != "" { - d := r.Description - out.Description = &d - } - if r.AuthConfig != nil { - out.AuthConfig = testAuthConfigStruct(r.AuthConfig) - } - if r.Metadata != nil { - m := api.ApiPortalMetadata(r.Metadata) - out.Metadata = &m - } - return out -} - -type testUpdateReq struct { - Name *string - Description *string - URL *string - AuthType *string - AuthConfig map[string]interface{} - Metadata map[string]interface{} -} - -func (r testUpdateReq) build() *api.UpdateApiPortalRequest { - out := &api.UpdateApiPortalRequest{ - Name: r.Name, - Description: r.Description, - Url: r.URL, - } - if r.AuthType != nil { - at := api.UpdateApiPortalRequestAuthType(*r.AuthType) - out.AuthType = &at - } - if r.AuthConfig != nil { - out.AuthConfig = testAuthConfigStruct(r.AuthConfig) - } - if r.Metadata != nil { - m := api.ApiPortalMetadata(r.Metadata) - out.Metadata = &m - } - return out -} - -func testAuthConfigStruct(m map[string]interface{}) *api.ApiPortalAuthConfig { - if m == nil { - return nil - } - c := &api.ApiPortalAuthConfig{} - if v, ok := m[constants.APIPortalAuthConfigKeySTSTokenURL].(string); ok { - s := v - c.StsTokenUrl = &s - } - if v, ok := m[constants.APIPortalAuthConfigKeyClientID].(string); ok { - s := v - c.ClientId = &s - } - if v, ok := m[constants.APIPortalAuthConfigKeyClientSecret].(string); ok { - s := v - c.ClientSecret = &s - } - return c -} - -// --- Create tests --- - -func TestAPIPortalService_CreateAPIPortal_HappyPath(t *testing.T) { - portalRepo := &mockAPIPortalRepository{} - orgRepo := &mockAPIPortalOrgRepository{result: &model.Organization{}} - auditRepo := &mockAPIPortalAuditRepository{} - svc := newTestAPIPortalService(t, portalRepo, orgRepo, auditRepo) - - req := testCreateReq{ - Handle: "acme", - Name: "Acme Portal", - Description: "test", - URL: "https://acme.example.com", - AuthType: constants.APIPortalAuthTypeLocal, - Metadata: map[string]interface{}{"stsIssuer": "https://sts.example.com"}, - } - got, err := svc.CreateAPIPortal(req.build(), "org-1", "user-1") - if err != nil { - t.Fatalf("CreateAPIPortal: %v", err) - } - if got == nil || derefStr(got.Handle) != "acme" || got.Name != "Acme Portal" { - t.Errorf("returned portal wrong shape: %+v", got) - } - if portalRepo.createCapturedInput == nil { - t.Fatal("repository Create not called") - } - // OSS registers a portal that's already running; status is always - // active from create, and is not exposed on the wire. - if portalRepo.createCapturedInput.Status != constants.APIPortalStatusActive { - t.Errorf("default status: want active, got %q", portalRepo.createCapturedInput.Status) - } - if portalRepo.createCapturedInput.ID == "" { - t.Error("expected generated UUID, got empty") - } - if portalRepo.createCapturedInput.CreatedBy != "user-1" || portalRepo.createCapturedInput.UpdatedBy != "user-1" { - t.Errorf("actor not populated: createdBy=%q updatedBy=%q", - portalRepo.createCapturedInput.CreatedBy, portalRepo.createCapturedInput.UpdatedBy) - } - if len(auditRepo.records) != 1 || auditRepo.records[0].action != "CREATE" { - t.Errorf("expected 1 CREATE audit record, got %+v", auditRepo.records) - } -} - -func TestAPIPortalService_CreateAPIPortal_MissingName(t *testing.T) { - svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}) - _, err := svc.CreateAPIPortal(testCreateReq{ - Handle: "acme", - AuthType: constants.APIPortalAuthTypeLocal, - }.build(), "org-1", "user-1") - if err == nil { - t.Fatal("expected error for missing name") - } - if !apperror.ValidationFailed.Is(err) { - t.Errorf("want ValidationFailed, got %v", err) - } -} - -func TestAPIPortalService_CreateAPIPortal_InvalidHandle(t *testing.T) { - svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}) - _, err := svc.CreateAPIPortal(testCreateReq{ - Handle: "AB", // too short + uppercase - Name: "x", - AuthType: constants.APIPortalAuthTypeLocal, - }.build(), "org-1", "user-1") - if err == nil { - t.Fatal("expected error for invalid handle") - } -} - -func TestAPIPortalService_CreateAPIPortal_InvalidAuthType(t *testing.T) { - svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}) - _, err := svc.CreateAPIPortal(testCreateReq{ - Handle: "acme", - Name: "Acme", - AuthType: "bogus", - }.build(), "org-1", "user-1") - if err == nil { - t.Fatal("expected error for invalid authType") - } - if !apperror.ValidationFailed.Is(err) { - t.Errorf("want ValidationFailed, got %v", err) - } -} - -func TestAPIPortalService_CreateAPIPortal_OrgNotFound(t *testing.T) { - svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: nil}, &mockAPIPortalAuditRepository{}) - _, err := svc.CreateAPIPortal(testCreateReq{ - Handle: "acme", Name: "Acme", AuthType: constants.APIPortalAuthTypeLocal, - URL: "https://acme.example.com", - }.build(), "org-missing", "user-1") - if err == nil || !apperror.OrganizationNotFound.Is(err) { - t.Fatalf("want OrganizationNotFound, got %v", err) - } -} - -func TestAPIPortalService_CreateAPIPortal_HandleAlreadyExists(t *testing.T) { - svc := newTestAPIPortalService(t, - &mockAPIPortalRepository{existsResult: true}, - &mockAPIPortalOrgRepository{result: &model.Organization{}}, - &mockAPIPortalAuditRepository{}, - ) - _, err := svc.CreateAPIPortal(testCreateReq{ - Handle: "acme", Name: "Acme", AuthType: constants.APIPortalAuthTypeLocal, - URL: "https://acme.example.com", - }.build(), "org-1", "user-1") - if err == nil || !apperror.APIPortalExists.Is(err) { - t.Fatalf("want APIPortalExists, got %v", err) - } -} - -func TestAPIPortalService_CreateAPIPortal_RaceOnUniqueConstraint(t *testing.T) { - // Exists() returns false (no row yet), then Create() races against another - // insert and hits the UNIQUE constraint. Service must translate to Conflict. - svc := newTestAPIPortalService(t, - &mockAPIPortalRepository{existsResult: false, createReturnUnique: true}, - &mockAPIPortalOrgRepository{result: &model.Organization{}}, - &mockAPIPortalAuditRepository{}, - ) - _, err := svc.CreateAPIPortal(testCreateReq{ - Handle: "acme", Name: "Acme", AuthType: constants.APIPortalAuthTypeLocal, - URL: "https://acme.example.com", - }.build(), "org-1", "user-1") - if err == nil || !apperror.APIPortalExists.Is(err) { - t.Fatalf("want APIPortalExists on race, got %v", err) - } -} - -func TestAPIPortalService_CreateAPIPortal_InvalidURL(t *testing.T) { - cases := []struct { - name string - url string - }{ - {"http_rejected", "http://portal.example.com"}, - {"file_scheme", "file:///etc/passwd"}, - {"metadata_service_http", "http://169.254.169.254/latest/meta-data/"}, - {"javascript_scheme", "javascript:alert(1)"}, - {"relative_url", "portal.example.com"}, - {"scheme_only", "https://"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - svc := newTestAPIPortalService(t, - &mockAPIPortalRepository{}, - &mockAPIPortalOrgRepository{result: &model.Organization{}}, - &mockAPIPortalAuditRepository{}, - ) - _, err := svc.CreateAPIPortal(testCreateReq{ - Handle: "acme", - Name: "Acme", - AuthType: constants.APIPortalAuthTypeLocal, - URL: tc.url, - }.build(), "org-1", "user-1") - if err == nil || !apperror.ValidationFailed.Is(err) { - t.Errorf("want ValidationFailed for %q, got %v", tc.url, err) - } - }) - } -} - -func TestAPIPortalService_CreateAPIPortal_ValidHTTPSAccepted(t *testing.T) { - svc := newTestAPIPortalService(t, - &mockAPIPortalRepository{}, - &mockAPIPortalOrgRepository{result: &model.Organization{}}, - &mockAPIPortalAuditRepository{}, - ) - got, err := svc.CreateAPIPortal(testCreateReq{ - Handle: "acme", - Name: "Acme", - AuthType: constants.APIPortalAuthTypeLocal, - URL: "https://portal.example.com:9443/base", - }.build(), "org-1", "user-1") - if err != nil { - t.Fatalf("valid https URL rejected: %v", err) - } - if got.Url != "https://portal.example.com:9443/base" { - t.Errorf("URL not preserved: %q", got.Url) - } -} - -func TestAPIPortalService_CreateAPIPortal_EmptyURLRejected(t *testing.T) { - // OSS requires the operator to supply a reachable URL. Empty is rejected. - svc := newTestAPIPortalService(t, - &mockAPIPortalRepository{}, - &mockAPIPortalOrgRepository{result: &model.Organization{}}, - &mockAPIPortalAuditRepository{}, - ) - _, err := svc.CreateAPIPortal(testCreateReq{ - Handle: "acme", - Name: "Acme", - AuthType: constants.APIPortalAuthTypeLocal, - URL: "", - }.build(), "org-1", "user-1") - if err == nil || !apperror.ValidationFailed.Is(err) { - t.Fatalf("want ValidationFailed for empty URL, got %v", err) - } -} - -func TestAPIPortalService_CreateAPIPortal_STSTokenURL_Rejected(t *testing.T) { - // stsTokenUrl is the outbound target of a client_credentials request - // carrying clientSecret; input-time checks enforce the same shape rules - // as the portal URL (absolute, host, https, non-empty). Host-based - // egress controls (loopback / private / metadata literal blocks, - // DNS-based resolve-and-recheck) belong in an operator-aware shared - // outbound HTTP client; local / on-prem deployments legitimately need - // https://localhost or private-range addresses here. - cases := []struct { - name string - url string - }{ - {"empty", ""}, - {"http_scheme", "http://sts.example.com/oauth2/token"}, - {"missing_scheme", "sts.example.com/oauth2/token"}, - {"file_scheme", "file:///etc/passwd"}, - {"javascript_scheme", "javascript:alert(1)"}, - {"scheme_only", "https://"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - svc := newTestAPIPortalService(t, - &mockAPIPortalRepository{}, - &mockAPIPortalOrgRepository{result: &model.Organization{}}, - &mockAPIPortalAuditRepository{}, - ) - _, err := svc.CreateAPIPortal(testCreateReq{ - Handle: "acme", - Name: "Acme", - URL: "https://acme.example.com", - AuthType: constants.APIPortalAuthTypeOAuth2, - AuthConfig: map[string]interface{}{ - "stsTokenUrl": tc.url, - "clientId": "abc", - "clientSecret": "s3cr3t", - }, - }.build(), "org-1", "user-1") - if err == nil || !apperror.ValidationFailed.Is(err) { - t.Errorf("want ValidationFailed for stsTokenUrl=%q, got %v", tc.url, err) - } - }) - } -} - -func TestAPIPortalService_CreateAPIPortal_STSTokenURL_Accepted(t *testing.T) { - // Positive control: a reachable-shaped https URL is accepted. - svc := newTestAPIPortalService(t, - &mockAPIPortalRepository{}, - &mockAPIPortalOrgRepository{result: &model.Organization{}}, - &mockAPIPortalAuditRepository{}, - ) - _, err := svc.CreateAPIPortal(testCreateReq{ - Handle: "acme", - Name: "Acme", - URL: "https://acme.example.com", - AuthType: constants.APIPortalAuthTypeOAuth2, - AuthConfig: map[string]interface{}{ - "stsTokenUrl": "https://sts.example.com/oauth2/token", - "clientId": "abc", - "clientSecret": "s3cr3t", - }, - }.build(), "org-1", "user-1") - if err != nil { - t.Fatalf("valid stsTokenUrl rejected: %v", err) - } -} - -func TestAPIPortalService_UpdateAPIPortal_SwitchOAuth2ToLocal(t *testing.T) { - // Regression: switching authType from oauth2 to local must clear the stored - // oauth2 authConfig — otherwise the post-mutation validator rejects the - // carried-over keys and no wire body can satisfy the request. - existing := &model.APIPortal{ - ID: "p1", Handle: "acme", OrganizationID: "org-1", - Name: "Acme", URL: "https://acme.example.com", - Status: constants.APIPortalStatusActive, - AuthType: constants.APIPortalAuthTypeOAuth2, - AuthConfig: map[string]interface{}{ - "stsTokenUrl": "https://sts.example.com/token", - "clientId": "abc", - "clientSecret": "already-ciphertext-base64", - }, - } - svc := newTestAPIPortalService(t, - &mockAPIPortalRepository{getResult: existing}, - &mockAPIPortalOrgRepository{}, - &mockAPIPortalAuditRepository{}, - ) - got, err := svc.UpdateAPIPortal("acme", testUpdateReq{ - AuthType: apiPortalStrPtr(constants.APIPortalAuthTypeLocal), - }.build(), "org-1", "editor") - if err != nil { - t.Fatalf("switch oauth2 → local: %v", err) - } - if string(got.AuthType) != constants.APIPortalAuthTypeLocal { - t.Errorf("authType not applied: %q", got.AuthType) - } - // After switching to local, the stored authConfig is cleared. The response's - // AuthConfig pointer either nil-outs or is an empty struct with no populated - // fields; use the captured model to assert the underlying map, since the - // response type doesn't expose the raw map. - captured := existing // Update mutates the pointer we passed in via getResult - if len(captured.AuthConfig) != 0 { - t.Errorf("stored authConfig not cleared on transition to local: %+v", captured.AuthConfig) - } -} - -func TestAPIPortalService_UpdateAPIPortal_InvalidURLRejected(t *testing.T) { - existing := &model.APIPortal{ - ID: "p1", Handle: "acme", OrganizationID: "org-1", - Name: "Acme", Status: constants.APIPortalStatusActive, - AuthType: constants.APIPortalAuthTypeLocal, - } - svc := newTestAPIPortalService(t, - &mockAPIPortalRepository{getResult: existing}, - &mockAPIPortalOrgRepository{}, - &mockAPIPortalAuditRepository{}, - ) - _, err := svc.UpdateAPIPortal("acme", testUpdateReq{ - URL: apiPortalStrPtr("http://insecure.example.com"), - }.build(), "org-1", "editor") - if err == nil || !apperror.ValidationFailed.Is(err) { - t.Fatalf("want ValidationFailed for http URL on Update, got %v", err) - } -} - -// --- Get tests --- - -func TestAPIPortalService_GetAPIPortal_HappyPath(t *testing.T) { - portal := &model.APIPortal{ID: "p1", Handle: "acme", OrganizationID: "org-1"} - svc := newTestAPIPortalService(t, - &mockAPIPortalRepository{getResult: portal}, - &mockAPIPortalOrgRepository{result: &model.Organization{}}, - &mockAPIPortalAuditRepository{}, - ) - got, err := svc.GetAPIPortal("acme", "org-1") - if err != nil { - t.Fatalf("GetAPIPortal: %v", err) - } - if got == nil || derefStr(got.Handle) != portal.Handle { - t.Errorf("returned portal wrong shape: %+v", got) - } -} - -func TestAPIPortalService_GetAPIPortal_NotFound(t *testing.T) { - svc := newTestAPIPortalService(t, &mockAPIPortalRepository{getResult: nil}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) - _, err := svc.GetAPIPortal("ghost", "org-1") - if err == nil || !apperror.APIPortalNotFound.Is(err) { - t.Fatalf("want APIPortalNotFound, got %v", err) - } -} - -// --- List tests --- - -func TestAPIPortalService_ListAPIPortals_HappyPath(t *testing.T) { - portals := []*model.APIPortal{{ID: "p1", Handle: "a"}, {ID: "p2", Handle: "b"}} - svc := newTestAPIPortalService(t, - &mockAPIPortalRepository{listResult: portals, countResult: 5}, - &mockAPIPortalOrgRepository{result: &model.Organization{}}, - &mockAPIPortalAuditRepository{}, - ) - resp, err := svc.ListAPIPortals("org-1", 0, 0, "", "", "") - if err != nil { - t.Fatalf("ListAPIPortals: %v", err) - } - if resp.Count != 2 || resp.Pagination.Total != 5 { - t.Errorf("counts wrong: %+v", resp) - } - if resp.Pagination.Limit != 20 { // default - t.Errorf("default limit not applied: %d", resp.Pagination.Limit) - } -} - -func TestAPIPortalService_ListAPIPortals_OrgNotFound(t *testing.T) { - svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: nil}, &mockAPIPortalAuditRepository{}) - _, err := svc.ListAPIPortals("org-missing", 0, 0, "", "", "") - if err == nil || !apperror.OrganizationNotFound.Is(err) { - t.Fatalf("want OrganizationNotFound, got %v", err) - } -} - -func TestAPIPortalService_ListAPIPortals_LimitClamping(t *testing.T) { - svc := newTestAPIPortalService(t, - &mockAPIPortalRepository{listResult: nil, countResult: 0}, - &mockAPIPortalOrgRepository{result: &model.Organization{}}, - &mockAPIPortalAuditRepository{}, - ) - resp, err := svc.ListAPIPortals("org-1", 500, -5, "", "", "") - if err != nil { - t.Fatalf("ListAPIPortals: %v", err) - } - if resp.Pagination.Limit != 100 { - t.Errorf("limit not clamped to 100: %d", resp.Pagination.Limit) - } - if resp.Pagination.Offset != 0 { - t.Errorf("negative offset not normalized to 0: %d", resp.Pagination.Offset) - } -} - -// --- Update tests --- - -func TestAPIPortalService_UpdateAPIPortal_HappyPath(t *testing.T) { - existing := &model.APIPortal{ - ID: "p1", Handle: "acme", OrganizationID: "org-1", - Name: "old", URL: "https://acme.example.com", - Status: constants.APIPortalStatusPending, - AuthType: constants.APIPortalAuthTypeLocal, - } - portalRepo := &mockAPIPortalRepository{getResult: existing} - auditRepo := &mockAPIPortalAuditRepository{} - svc := newTestAPIPortalService(t, portalRepo, &mockAPIPortalOrgRepository{}, auditRepo) - - req := testUpdateReq{ - Name: apiPortalStrPtr("Renamed"), - AuthType: apiPortalStrPtr(constants.APIPortalAuthTypeOAuth2), - AuthConfig: map[string]interface{}{ - "stsTokenUrl": "https://sts.example.com/token", - "clientId": "abc", - "clientSecret": "s3cr3t", - }, - } - got, err := svc.UpdateAPIPortal("acme", req.build(), "org-1", "editor") - if err != nil { - t.Fatalf("UpdateAPIPortal: %v", err) - } - if got.Name != "Renamed" || string(got.AuthType) != constants.APIPortalAuthTypeOAuth2 { - t.Errorf("mutable fields not applied: %+v", got) - } - if derefStr(got.Handle) != "acme" || derefStr(got.Id) != "acme" { - t.Errorf("immutable fields changed: %+v", got) - } - if portalRepo.updateCapturedInput == nil { - t.Fatal("repository Update not called") - } - if portalRepo.updateCapturedInput.UpdatedBy != "editor" { - t.Errorf("updatedBy not populated: %q", portalRepo.updateCapturedInput.UpdatedBy) - } - if len(auditRepo.records) != 1 || auditRepo.records[0].action != "UPDATE" { - t.Errorf("expected 1 UPDATE audit record, got %+v", auditRepo.records) - } -} - -func TestAPIPortalService_UpdateAPIPortal_PartialUpdate(t *testing.T) { - existing := &model.APIPortal{ - ID: "p1", Handle: "acme", OrganizationID: "org-1", - Name: "keep", URL: "https://keep.example.com", - Status: constants.APIPortalStatusActive, - AuthType: constants.APIPortalAuthTypeLocal, - } - svc := newTestAPIPortalService(t, &mockAPIPortalRepository{getResult: existing}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) - // Only Description supplied; everything else must remain unchanged. - got, err := svc.UpdateAPIPortal("acme", testUpdateReq{Description: apiPortalStrPtr("new desc")}.build(), "org-1", "editor") - if err != nil { - t.Fatalf("UpdateAPIPortal: %v", err) - } - if derefStr(got.Description) != "new desc" { - t.Errorf("Description not updated: %q", derefStr(got.Description)) - } - if got.Name != "keep" || got.Url != "https://keep.example.com" || - string(got.AuthType) != constants.APIPortalAuthTypeLocal { - t.Errorf("unset fields were mutated: %+v", got) - } -} - -func TestAPIPortalService_UpdateAPIPortal_NotFound(t *testing.T) { - svc := newTestAPIPortalService(t, &mockAPIPortalRepository{getResult: nil}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) - _, err := svc.UpdateAPIPortal("ghost", testUpdateReq{Name: apiPortalStrPtr("x")}.build(), "org-1", "editor") - if err == nil || !apperror.APIPortalNotFound.Is(err) { - t.Fatalf("want APIPortalNotFound, got %v", err) - } -} - -func TestAPIPortalService_UpdateAPIPortal_EmptyName(t *testing.T) { - existing := &model.APIPortal{ID: "p1", Handle: "acme", OrganizationID: "org-1", Name: "old"} - svc := newTestAPIPortalService(t, &mockAPIPortalRepository{getResult: existing}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) - _, err := svc.UpdateAPIPortal("acme", testUpdateReq{Name: apiPortalStrPtr(" ")}.build(), "org-1", "editor") - if err == nil || !apperror.ValidationFailed.Is(err) { - t.Fatalf("want ValidationFailed for empty name, got %v", err) - } -} - -// --- Delete tests --- - -func TestAPIPortalService_DeleteAPIPortal_HappyPath(t *testing.T) { - existing := &model.APIPortal{ID: "p1", Handle: "acme", OrganizationID: "org-1"} - portalRepo := &mockAPIPortalRepository{getResult: existing} - auditRepo := &mockAPIPortalAuditRepository{} - svc := newTestAPIPortalService(t, portalRepo, &mockAPIPortalOrgRepository{}, auditRepo) - if err := svc.DeleteAPIPortal("acme", "org-1", "actor"); err != nil { - t.Fatalf("DeleteAPIPortal: %v", err) - } - if portalRepo.deleteCalledWith != [2]string{"p1", "org-1"} { - t.Errorf("Delete called with wrong args: %+v", portalRepo.deleteCalledWith) - } - if len(auditRepo.records) != 1 || auditRepo.records[0].action != "DELETE" { - t.Errorf("expected 1 DELETE audit record, got %+v", auditRepo.records) - } -} - -func TestAPIPortalService_DeleteAPIPortal_NotFound(t *testing.T) { - svc := newTestAPIPortalService(t, &mockAPIPortalRepository{getResult: nil}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) - err := svc.DeleteAPIPortal("ghost", "org-1", "actor") - if err == nil || !apperror.APIPortalNotFound.Is(err) { - t.Fatalf("want APIPortalNotFound, got %v", err) - } -} diff --git a/platform-api/internal/service/api_portal_translate.go b/platform-api/internal/service/api_portal_translate.go index 876c6842f3..67749eabd3 100644 --- a/platform-api/internal/service/api_portal_translate.go +++ b/platform-api/internal/service/api_portal_translate.go @@ -19,7 +19,6 @@ package service import ( "github.com/wso2/api-platform/platform-api/api" - "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/model" ) @@ -27,7 +26,7 @@ import ( // pdk-facing wrappers on APIPortalService. // derefAPIPortalMetadata converts the generated Metadata type (a map alias) -// into a plain map[string]interface{} the service works in. Nil in → nil out. +// into a plain map[string]interface{} the service works in. Nil in -> nil out. func derefAPIPortalMetadata(m *api.ApiPortalMetadata) map[string]interface{} { if m == nil { return nil @@ -35,70 +34,12 @@ func derefAPIPortalMetadata(m *api.ApiPortalMetadata) map[string]interface{} { return map[string]interface{}(*m) } -// authConfigStructToMap flattens the generated ApiPortalAuthConfig struct into -// the map shape service-layer validation and encryption operate on. Nil pointer -// fields are dropped so validation sees "missing" rather than "present but -// empty". -func authConfigStructToMap(c *api.ApiPortalAuthConfig) map[string]interface{} { - if c == nil { - return nil - } - out := map[string]interface{}{} - if c.StsTokenUrl != nil { - out[constants.APIPortalAuthConfigKeySTSTokenURL] = *c.StsTokenUrl - } - if c.ClientId != nil { - out[constants.APIPortalAuthConfigKeyClientID] = *c.ClientId - } - if c.ClientSecret != nil { - out[constants.APIPortalAuthConfigKeyClientSecret] = *c.ClientSecret - } - return out -} - -// stripSensitiveAuthConfig removes keys that carry secret material. Called -// before authConfig leaves the server, alongside the OAS `writeOnly: true` -// marker on ClientSecret — even if the storage-encrypt step is ever skipped, -// the response strip guarantees secrets never appear on the wire. -func stripSensitiveAuthConfig(cfg map[string]interface{}) map[string]interface{} { - if cfg == nil { - return nil - } - out := make(map[string]interface{}, len(cfg)) - for k, v := range cfg { - out[k] = v - } - for _, key := range constants.APIPortalAuthConfigSensitiveKeys { - delete(out, key) - } - return out -} - -// mapToAuthConfigStruct rebuilds the generated struct from the stored map for -// response serialization. Sensitive keys are stripped first, so the generated -// ClientSecret pointer stays nil (and — since it's marked omitempty — won't -// appear in the JSON output). -func mapToAuthConfigStruct(m map[string]interface{}) *api.ApiPortalAuthConfig { - stripped := stripSensitiveAuthConfig(m) - if stripped == nil { - return nil - } - c := &api.ApiPortalAuthConfig{} - if v, ok := stripped[constants.APIPortalAuthConfigKeySTSTokenURL].(string); ok && v != "" { - s := v - c.StsTokenUrl = &s - } - if v, ok := stripped[constants.APIPortalAuthConfigKeyClientID].(string); ok && v != "" { - s := v - c.ClientId = &s - } - // ClientSecret is intentionally never populated on the response side. - return c -} - // ModelToAPIPortalResponse converts an internal model.APIPortal into the // api-generated ApiPortalResponse. Exported so the HTTP handler can serialize -// what the service returns. +// what the service returns. The InternalAuthKey field is NEVER surfaced, +// the only path for a client to see the shared key is the write-only field +// on Create/Update requests, and that value is not stored in a form that can +// be re-read. func ModelToAPIPortalResponse(p *model.APIPortal) *api.ApiPortalResponse { if p == nil { return nil @@ -113,7 +54,6 @@ func ModelToAPIPortalResponse(p *model.APIPortal) *api.ApiPortalResponse { Handle: &handle, Name: p.Name, Url: p.URL, - AuthType: api.ApiPortalResponseAuthType(p.AuthType), CreatedAt: &createdAt, UpdatedAt: &updatedAt, } @@ -121,9 +61,6 @@ func ModelToAPIPortalResponse(p *model.APIPortal) *api.ApiPortalResponse { desc := p.Description resp.Description = &desc } - if p.AuthConfig != nil { - resp.AuthConfig = mapToAuthConfigStruct(p.AuthConfig) - } if p.Metadata != nil { m := api.ApiPortalMetadata(p.Metadata) resp.Metadata = &m @@ -132,14 +69,13 @@ func ModelToAPIPortalResponse(p *model.APIPortal) *api.ApiPortalResponse { } // modelToAPIPortalListItem projects a model.APIPortal onto the list-response -// item type (excludes authConfig and metadata by design). +// item type (excludes metadata by design, and never carries the shared key). func modelToAPIPortalListItem(p *model.APIPortal) api.ApiPortalListItem { item := api.ApiPortalListItem{ Id: p.Handle, Handle: p.Handle, Name: p.Name, Url: p.URL, - AuthType: api.ApiPortalListItemAuthType(p.AuthType), CreatedAt: p.CreatedAt, } if p.Description != "" { diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index 7f0d2efeed..2344f93c5a 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -8823,34 +8823,6 @@ components: pagination: $ref: '#/components/schemas/Pagination' - ApiPortalAuthConfig: - title: API Portal outbound authentication material - type: object - description: | - Platform-API's outbound authentication material for the portal admin - API. Shape depends on `authType`: - - `local` → must be empty. - - `oauth2` → `stsTokenUrl`, `clientId`, `clientSecret` are all required. - `clientSecret` is write-only: accepted on create/update requests, persisted - encrypted at rest, and never returned on read. - additionalProperties: false - properties: - stsTokenUrl: - type: string - format: uri - description: Token endpoint of the STS Platform-API POSTs the client_credentials grant to. - example: "https://sts.example.com/oauth2/token" - clientId: - type: string - description: Registered client identifier in the STS. - example: "acme-portal-client" - clientSecret: - type: string - writeOnly: true - description: >- - Registered client secret. Accepted only in create/update requests; - never returned in responses. Persisted encrypted server-side. - ApiPortalMetadata: title: API Portal metadata type: object @@ -8869,13 +8841,12 @@ components: - name - handle - url - - authType - createdAt - updatedAt properties: id: type: string - description: Handle (URL-friendly slug) of the API Portal — primary identifier. + description: Handle (URL-friendly slug) of the API Portal, primary identifier. pattern: '^[a-z0-9-]+$' minLength: 3 maxLength: 40 @@ -8904,15 +8875,6 @@ components: format: uri description: Public URL of the API Portal. Operator-supplied. example: "https://acme-portal.example.com" - authType: - type: string - enum: [local, oauth2] - description: >- - Determines how Platform API authenticates to the portal's admin API - and selects the shape of the `config` object. - example: "oauth2" - authConfig: - $ref: '#/components/schemas/ApiPortalAuthConfig' metadata: $ref: '#/components/schemas/ApiPortalMetadata' createdAt: @@ -8927,15 +8889,14 @@ components: example: "2026-08-13T10:30:00Z" ApiPortalListItem: - title: API Portal — list projection - description: Lightweight projection returned in collection responses (excludes the `config` blob). + title: API Portal list projection + description: Lightweight projection returned in collection responses (excludes the metadata blob). type: object required: - id - name - handle - url - - authType - createdAt properties: id: @@ -8955,9 +8916,6 @@ components: url: type: string format: uri - authType: - type: string - enum: [local, oauth2] createdAt: type: string format: date-time @@ -8969,7 +8927,7 @@ components: - name - handle - url - - authType + - sharedKey properties: name: type: string @@ -8989,17 +8947,24 @@ components: type: string format: uri description: Public URL of the API Portal to register. Operator-supplied. - authType: + sharedKey: type: string - enum: [local, oauth2] - authConfig: - $ref: '#/components/schemas/ApiPortalAuthConfig' + writeOnly: true + pattern: '^[0-9a-fA-F]{64}$' + minLength: 64 + maxLength: 64 + description: >- + The raw shared key Platform-API will send as `Authorization: SharedKey ` on + outbound publishing calls. The portal side stores only the sha256 hash of this + value (generated via portals/scripts/setup.sh). Persisted encrypted at rest here; + never returned on any read. + example: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" metadata: $ref: '#/components/schemas/ApiPortalMetadata' UpdateApiPortalRequest: title: Update API Portal request - description: All fields optional. Only mutable fields are accepted — see field permissions in the design doc. + description: All fields optional. Only mutable fields are accepted, see field permissions in the design doc. type: object properties: name: @@ -9013,11 +8978,15 @@ components: url: type: string format: uri - authType: + sharedKey: type: string - enum: [local, oauth2] - authConfig: - $ref: '#/components/schemas/ApiPortalAuthConfig' + writeOnly: true + pattern: '^[0-9a-fA-F]{64}$' + minLength: 64 + maxLength: 64 + description: >- + Rotate the shared key. When present, replaces the stored value. Same format as on + Create. Write-only; never returned. metadata: $ref: '#/components/schemas/ApiPortalMetadata' From 4e737baac226a621b9e90a4df6f53a35f406ff10 Mon Sep 17 00:00:00 2001 From: dushaniw Date: Wed, 9 Sep 2026 17:52:49 +0530 Subject: [PATCH 21/25] api-portals: make metadata column nullable, store NULL for empty maps The metadata blob is an opaque plugin-owned pass-through. On the OSS side nothing populates it, so persisting an empty JSON object `{}` was just noise. Make the column nullable and let the DAO store SQL NULL when the caller supplies nothing. - Schema (all 3 engines): drop `NOT NULL` on `metadata`. - `marshalAPIPortalBlob`: return nil bytes for a nil or empty map (driver renders as SQL NULL) instead of `{}`. Non-empty maps still serialise as JSON. - `unmarshalAPIPortalBlob`: return a nil map for a NULL / empty-bytes read. The model's `json:"metadata,omitempty"` tag then elides the field on the response wire for portals that carry no metadata. Build + vet clean; repository and service test packages pass unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../internal/database/schema.postgres.sql | 2 +- .../internal/database/schema.sqlite.sql | 2 +- .../internal/database/schema.sqlserver.sql | 2 +- .../internal/repository/api_portal.go | 31 ++++++++++--------- 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/platform-api/internal/database/schema.postgres.sql b/platform-api/internal/database/schema.postgres.sql index 5035264125..6f0ecb3978 100644 --- a/platform-api/internal/database/schema.postgres.sql +++ b/platform-api/internal/database/schema.postgres.sql @@ -409,7 +409,7 @@ CREATE TABLE IF NOT EXISTS api_portals ( url VARCHAR(500), status VARCHAR(20) NOT NULL DEFAULT 'pending', internal_auth_key BYTEA NOT NULL, - metadata BYTEA NOT NULL, + metadata BYTEA, created_by VARCHAR(200), created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(200), diff --git a/platform-api/internal/database/schema.sqlite.sql b/platform-api/internal/database/schema.sqlite.sql index 4aa0f856b9..3c9d84b6b0 100644 --- a/platform-api/internal/database/schema.sqlite.sql +++ b/platform-api/internal/database/schema.sqlite.sql @@ -409,7 +409,7 @@ CREATE TABLE IF NOT EXISTS api_portals ( url VARCHAR(500), status VARCHAR(20) NOT NULL DEFAULT 'pending', internal_auth_key BLOB NOT NULL, - metadata BLOB NOT NULL, + metadata BLOB, created_by VARCHAR(200), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(200), diff --git a/platform-api/internal/database/schema.sqlserver.sql b/platform-api/internal/database/schema.sqlserver.sql index f22ad20cd5..7f25933c1c 100644 --- a/platform-api/internal/database/schema.sqlserver.sql +++ b/platform-api/internal/database/schema.sqlserver.sql @@ -461,7 +461,7 @@ CREATE TABLE dbo.api_portals ( url VARCHAR(500), status VARCHAR(20) NOT NULL DEFAULT 'pending', internal_auth_key VARBINARY(MAX) NOT NULL, - metadata VARBINARY(MAX) NOT NULL, + metadata VARBINARY(MAX), created_by VARCHAR(200), created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), updated_by VARCHAR(200), diff --git a/platform-api/internal/repository/api_portal.go b/platform-api/internal/repository/api_portal.go index bd88e9224b..0cab38d228 100644 --- a/platform-api/internal/repository/api_portal.go +++ b/platform-api/internal/repository/api_portal.go @@ -72,13 +72,14 @@ func scanAPIPortalRow(scanner interface { return portal, nil } -// marshalAPIPortalBlob serializes a JSON blob column value. A nil map becomes an -// empty JSON object so the NOT NULL BYTEA/BLOB/VARBINARY column always has -// valid content; readers (unmarshalAPIPortalBlob) mirror this by normalizing -// empty/{} back to an empty map so callers never nil-check. +// marshalAPIPortalBlob serializes a JSON blob column value. A nil or empty map +// becomes a nil byte slice so the driver stores SQL NULL — the column is +// nullable and there is no reason to distinguish "operator supplied nothing" +// from "operator supplied {}". readers (unmarshalAPIPortalBlob) mirror this +// by returning a nil map for a NULL or empty-bytes read. func marshalAPIPortalBlob(m map[string]interface{}, field string) ([]byte, error) { - if m == nil { - return []byte("{}"), nil + if len(m) == 0 { + return nil, nil } b, err := json.Marshal(m) if err != nil { @@ -87,17 +88,17 @@ func marshalAPIPortalBlob(m map[string]interface{}, field string) ([]byte, error return b, nil } -// unmarshalAPIPortalBlob deserializes a JSON blob and normalizes the result to -// a non-nil map. +// unmarshalAPIPortalBlob deserializes a JSON blob. Returns a nil map for a +// NULL column value or empty bytes so the response can rely on the model's +// `json:",omitempty"` tag to elide the field entirely for portals that carry +// no metadata (typical OSS case). func unmarshalAPIPortalBlob(b []byte, field string) (map[string]interface{}, error) { - m := map[string]interface{}{} - if len(b) > 0 { - if err := json.Unmarshal(b, &m); err != nil { - return nil, fmt.Errorf("failed to unmarshal %s: %w", field, err) - } + if len(b) == 0 { + return nil, nil } - if m == nil { - m = map[string]interface{}{} + m := map[string]interface{}{} + if err := json.Unmarshal(b, &m); err != nil { + return nil, fmt.Errorf("failed to unmarshal %s: %w", field, err) } return m, nil } From 4ac1f42bd6389ec9fb21b05b52d426a7bfd827a0 Mon Sep 17 00:00:00 2001 From: dushaniw Date: Thu, 10 Sep 2026 16:49:51 +0530 Subject: [PATCH 22/25] api-portals: implement SharedKeyAuthProvider + registry Get MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the earlier stub that returned "not yet implemented" from APIPortalAuthRegistry.Get. Platform-API's outbound publisher now has a real path to obtain the Authorization header for a portal: hdr, err := apiPortalService.AuthHeaderForPortal(ctx, handle, orgID) // hdr == "SharedKey " Companion side of the shared-key mechanism landed on: - wso2/api-platform#3243 devportal middleware + config + setup.sh (raw generation) - wso2-enterprise/apim-saas#2957 cloud plugin (raw generation on create + hash into OpenBao) - wso2-enterprise/wso2cloud#995 RT template (hash projected into portal pod) - internal/service/api_portal_auth.go — full rewrite - sharedKeyAuthProvider: decrypts the row's internal_auth_key ONCE at construction (via vault.SecretVault), holds the "SharedKey " header string, returns it on every AuthorizationHeader. No per-call decrypt cost, no per-provider mutex on the hot path. - NewSharedKeyAuthProvider(vault, encryptedKey) constructor with input validation (non-nil vault, non-empty ciphertext). - APIPortalAuthRegistry now takes portalRepo + vault. Get(handle, orgID) misses the cache -> loads row via portalRepo.GetByHandleAndOrgID -> constructs provider -> caches under registryKey(orgID, handle). Double-check pattern under mutex so concurrent Gets for the same key resolve to a single stored instance (a lost race just discards one just-constructed provider, no correctness impact). - registryKey = orgID + "/" + handle so the same handle in two orgs never cross-contaminates. - Invalidate(handle, orgID) drops the cached entry — idempotent, safe from Delete paths. - internal/service/api_portal.go - invalidateCachedAuthProvider now takes (handle, orgID); the two callers (Update + Delete) pass portal.OrganizationID. - AuthHeaderForPortal(ctx, handle, orgID) — the one-line surface the future publisher code calls. Wraps registry.Get + provider.AuthorizationHeader. Returns APIPortalNotFound for unknown portals, plain errors on decryption / config problems (permanent failures — callers should not retry). - internal/server/server.go - NewAPIPortalAuthRegistry() now takes apiPortalRepo + secretVault (the two deps already in scope on either side of the call). Build + vet + non-disabled tests all pass in both platform-api and the apim-saas plugin that symlinks into it. Co-Authored-By: Claude Opus 4.7 (1M context) --- platform-api/internal/server/server.go | 2 +- platform-api/internal/service/api_portal.go | 28 ++- .../internal/service/api_portal_auth.go | 179 +++++++++++++----- 3 files changed, 160 insertions(+), 49 deletions(-) diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index fd4e5af755..da177e8101 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -326,7 +326,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, return nil, fmt.Errorf("failed to initialize secret vault: %w", vaultErr) } secretService := service.NewSecretService(secretRepo, secretVault, identityService) - apiPortalAuthRegistry := service.NewAPIPortalAuthRegistry() + apiPortalAuthRegistry := service.NewAPIPortalAuthRegistry(apiPortalRepo, secretVault) apiPortalService := service.NewAPIPortalService(apiPortalRepo, orgRepo, auditRepo, secretVault, apiPortalAuthRegistry, identityService, slogger) // Initialize handlers diff --git a/platform-api/internal/service/api_portal.go b/platform-api/internal/service/api_portal.go index a29f2b3a14..ca9ec82c8e 100644 --- a/platform-api/internal/service/api_portal.go +++ b/platform-api/internal/service/api_portal.go @@ -19,6 +19,7 @@ package service import ( "context" + "fmt" "log/slog" "net/url" "regexp" @@ -130,11 +131,30 @@ func NewAPIPortalService( // invalidateCachedAuthProvider is a no-op when the service was constructed // without a registry (e.g. in unit tests that don't need outbound auth). Keeps // call sites clean of nil checks. -func (s *APIPortalService) invalidateCachedAuthProvider(portalHandle string) { +func (s *APIPortalService) invalidateCachedAuthProvider(portalHandle, orgID string) { if s.authRegistry == nil { return } - s.authRegistry.Invalidate(portalHandle) + s.authRegistry.Invalidate(portalHandle, orgID) +} + +// AuthHeaderForPortal returns the fully-formed Authorization header value the +// outbound publisher should attach to its next call to the portal's admin +// REST API, e.g. "SharedKey ". Wraps the registry lookup + provider +// caching so publisher code is a one-liner: `hdr, err := svc.AuthHeaderForPortal(ctx, handle, orgID)`. +// +// Returns APIPortalNotFound when the (handle, orgID) pair is unknown, and a +// plain error on decryption / configuration failures (the caller treats +// those as fatal for the publish call rather than retrying). +func (s *APIPortalService) AuthHeaderForPortal(ctx context.Context, portalHandle, orgID string) (string, error) { + if s.authRegistry == nil { + return "", fmt.Errorf("shared-key AuthProvider registry is not initialised") + } + provider, err := s.authRegistry.Get(portalHandle, orgID) + if err != nil { + return "", err + } + return provider.AuthorizationHeader(ctx) } // PaginationInfo is the {total, offset, limit} triplet used to build the @@ -333,7 +353,7 @@ func (s *APIPortalService) UpdateAPIPortal(handle string, req *api.UpdateApiPort _ = s.auditRepo.Record("UPDATE", portal.ID, "api_portal", orgID, portal.UpdatedBy) // Config may have changed; drop any cached AuthProvider so the next // outbound call rebuilds from the new stored values. - s.invalidateCachedAuthProvider(portal.Handle) + s.invalidateCachedAuthProvider(portal.Handle, portal.OrganizationID) return ModelToAPIPortalResponse(portal), nil } @@ -350,6 +370,6 @@ func (s *APIPortalService) DeleteAPIPortal(handle, orgID, actor string) error { return err } _ = s.auditRepo.Record("DELETE", portal.ID, "api_portal", orgID, strings.TrimSpace(actor)) - s.invalidateCachedAuthProvider(portal.Handle) + s.invalidateCachedAuthProvider(portal.Handle, portal.OrganizationID) return nil } diff --git a/platform-api/internal/service/api_portal_auth.go b/platform-api/internal/service/api_portal_auth.go index c2e7dd8e7c..fbc171a691 100644 --- a/platform-api/internal/service/api_portal_auth.go +++ b/platform-api/internal/service/api_portal_auth.go @@ -15,82 +15,173 @@ * */ -// Placeholder for the shared-key outbound authentication path. +// Shared-key outbound authentication for Platform-API → API Portal admin API +// calls. Platform-API stores each portal's shared-key encrypted-at-rest in the +// api_portals.internal_auth_key column; the outbound publisher calls +// AuthHeaderForPortal to get the "SharedKey " header value to attach to +// each publish request. The registry decrypts each portal's key exactly once +// (on first use), caches the plaintext in memory, and drops it when the row +// changes (Update / Delete). See SharedKey-Auth-Design.md for the mechanism. // -// The CRUD surface for /api-portals now stores an encrypted shared key on each -// row (model.APIPortal.InternalAuthKey). The provider that decrypts that value -// and returns `Authorization: SharedKey ` for every publish call is a -// self-contained subsystem tracked as follow-up work, this file keeps only the -// interface and the registry hook the service and its callers already speak -// against, so the branch stays compilable while the implementation lands -// separately. -// -// See the design doc: Projects/DevPortal Publishing/Platform-API-Devportal-Design/ -// SharedKey-Auth-Design.md (§Platform-API side). +// The registry is instantiated once at server startup and shared by every +// publisher; concurrent Gets for the same portal race safely (map is guarded +// by a mutex; a lost race is idempotent, both callers get equivalent +// providers). package service import ( "context" - "errors" + "fmt" "sync" + + "github.com/wso2/api-platform/platform-api/internal/apperror" + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/repository" + "github.com/wso2/api-platform/platform-api/internal/vault" ) // AuthProvider is the outbound-auth surface exposed to any component that // needs to call a portal's admin REST endpoints. type AuthProvider interface { - // AuthorizationHeader returns the "SharedKey " header value for the - // next outbound call. Concrete implementations decrypt the row's stored - // key (OSS) or resolve it out of a secret backend (cloud) and cache the - // plaintext in memory. + // AuthorizationHeader returns the fully-formed Authorization header value + // for the next outbound publish call to this portal, e.g. + // "SharedKey ". Provider implementations decrypt / cache the raw + // once at construction and hand out the same header for the provider's + // lifetime; rotation of the underlying stored key is handled at the + // registry level (Invalidate drops the cached provider so the next Get + // re-reads the row and re-decrypts). AuthorizationHeader(ctx context.Context) (string, error) - // InvalidateCache clears any cached plaintext so the next call re-reads - // from source. Callers invoke this after the caller-visible row updates. + // InvalidateCache is a per-provider no-op today. Included on the + // interface so future provider implementations (e.g. one that fetches + // the raw from OpenBao on every call) can flush an in-provider cache + // without a whole-registry drop. InvalidateCache() } -// APIPortalAuthRegistry keeps at most one AuthProvider per portal handle. The -// service's Update / Delete paths call Invalidate so the next outbound call -// picks up whatever the row now says. Concrete provider construction is the -// caller's problem, this type only manages the cache. +// sharedKeyAuthProvider serves "SharedKey " for a single portal. Fields +// are read-only after construction; safe to share across goroutines. +type sharedKeyAuthProvider struct { + header string // "SharedKey " — the exact bytes sent on the wire +} + +// NewSharedKeyAuthProvider constructs a provider from a portal's encrypted +// shared-key bytes. Decryption happens once, the plaintext lives inside the +// returned provider (never persisted, never re-encrypted). +func NewSharedKeyAuthProvider(v vault.SecretVault, encryptedKey []byte) (AuthProvider, error) { + if v == nil { + return nil, fmt.Errorf("shared-key AuthProvider: vault is nil") + } + if len(encryptedKey) == 0 { + return nil, fmt.Errorf("shared-key AuthProvider: encrypted key is empty") + } + raw, err := v.Decrypt(context.Background(), encryptedKey) + if err != nil { + return nil, fmt.Errorf("shared-key AuthProvider: decrypt: %w", err) + } + return &sharedKeyAuthProvider{ + header: constants.APIPortalSharedKeyAuthScheme + " " + raw, + }, nil +} + +func (p *sharedKeyAuthProvider) AuthorizationHeader(_ context.Context) (string, error) { + return p.header, nil +} + +func (p *sharedKeyAuthProvider) InvalidateCache() { + // no-op: SharedKey plaintext is fixed for a provider's lifetime; when + // the stored key rotates, the service calls registry.Invalidate which + // drops the whole provider so the next Get rebuilds from the fresh row. +} + +// APIPortalAuthRegistry holds at most one AuthProvider per portal (keyed by +// orgID + handle). The service's Update / Delete paths call Invalidate so the +// next outbound call picks up whatever the row now says. Provider construction +// (Decrypt on the row's internal_auth_key) happens on Get miss. type APIPortalAuthRegistry struct { - mu sync.Mutex - providers map[string]AuthProvider + mu sync.Mutex + providers map[string]AuthProvider // key = registryKey(orgID, handle) + portalRepo repository.APIPortalRepository + vault vault.SecretVault +} + +// NewAPIPortalAuthRegistry constructs the registry. portalRepo is used to load +// a row's internal_auth_key when Get misses the cache; vault is used to +// decrypt that value. +func NewAPIPortalAuthRegistry(portalRepo repository.APIPortalRepository, v vault.SecretVault) *APIPortalAuthRegistry { + return &APIPortalAuthRegistry{ + providers: map[string]AuthProvider{}, + portalRepo: portalRepo, + vault: v, + } } -// NewAPIPortalAuthRegistry constructs an empty registry. -func NewAPIPortalAuthRegistry() *APIPortalAuthRegistry { - return &APIPortalAuthRegistry{providers: map[string]AuthProvider{}} +// registryKey composes a stable per-(org, portal) cache key. Same handle in +// different orgs get different entries so a Get for one org never returns +// another org's provider — the DB row lookup would fail cross-org anyway +// (GetByHandleAndOrgID filters on organization_uuid), but keeping the cache +// keyed on both means the miss path stays correct without racing. +func registryKey(orgID, portalHandle string) string { + return orgID + "/" + portalHandle } -// Invalidate drops the cached provider for a portal handle. No-op when the -// handle has no cached entry (idempotent, safe to call from Delete paths). -func (r *APIPortalAuthRegistry) Invalidate(portalHandle string) { +// Invalidate drops the cached provider for a portal handle in an org. No-op +// when there is no cached entry (idempotent, safe to call from Delete paths). +// Called by the service on every Update / Delete of a portal row. +func (r *APIPortalAuthRegistry) Invalidate(portalHandle, orgID string) { if r == nil { return } r.mu.Lock() defer r.mu.Unlock() - delete(r.providers, portalHandle) + delete(r.providers, registryKey(orgID, portalHandle)) } -// errSharedKeyProviderNotImplemented is returned by Get until the SharedKey -// provider implementation lands. Removed once the follow-up PR wires the real -// provider through here. -var errSharedKeyProviderNotImplemented = errors.New("shared-key AuthProvider not yet implemented; see SharedKey-Auth-Design.md") - -// Get returns a cached AuthProvider for the portal handle, or an error when -// none is configured. The provider construction path is deferred to the -// follow-up SharedKey work, callers today only need Invalidate to be safe. -func (r *APIPortalAuthRegistry) Get(portalHandle string) (AuthProvider, error) { +// Get returns the AuthProvider for the (org, portal) pair, constructing + +// caching on first call. Returns APIPortalNotFound when the row is not +// present. Any decryption failure (row bytes not encrypted with the current +// vault key, or corrupted ciphertext) surfaces as a plain error the caller +// treats as a permanent configuration problem. +// +// Concurrent Gets for the same key race safely: the first one wins the map +// slot, subsequent ones return that stored provider (double-check under lock +// avoids constructing more than once). A rare double-decrypt on a lost race +// is preferable to holding the map lock across an I/O call to portalRepo. +func (r *APIPortalAuthRegistry) Get(portalHandle, orgID string) (AuthProvider, error) { if r == nil { - return nil, errSharedKeyProviderNotImplemented + return nil, fmt.Errorf("shared-key AuthProvider registry is not initialised") } + key := registryKey(orgID, portalHandle) + r.mu.Lock() - defer r.mu.Unlock() - if p, ok := r.providers[portalHandle]; ok { + if p, ok := r.providers[key]; ok { + r.mu.Unlock() return p, nil } - return nil, errSharedKeyProviderNotImplemented + r.mu.Unlock() + + portal, err := r.portalRepo.GetByHandleAndOrgID(portalHandle, orgID) + if err != nil { + return nil, err + } + if portal == nil { + return nil, apperror.APIPortalNotFound.New() + } + + provider, err := NewSharedKeyAuthProvider(r.vault, portal.InternalAuthKey) + if err != nil { + return nil, err + } + + r.mu.Lock() + // Another goroutine may have installed a provider while we were + // decrypting; prefer the existing one to keep a single instance per key. + if existing, ok := r.providers[key]; ok { + r.mu.Unlock() + return existing, nil + } + r.providers[key] = provider + r.mu.Unlock() + return provider, nil } From 56edf4e0f83f5665c7d197b81edf3ccc42840757 Mon Sep 17 00:00:00 2001 From: dushaniw Date: Fri, 11 Sep 2026 12:28:37 +0530 Subject: [PATCH 23/25] api-portals: restore repo/service/handler test coverage for shared-key auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three api-portal test files were disabled during the OAuth2 → shared-key migration. Rewritten from the ground up against the new model: sharedKey inbound, InternalAuthKey (AES-GCM ciphertext) at rest, no AuthType / AuthConfig fields anywhere. - repository/api_portal_test.go: internal_auth_key round-trip (binary-safe), nullable metadata column, org isolation. - service/api_portal_test.go: sharedKey validation (hex + length), encrypt-on-write via InHouseVault, PUT-with-sharedKey rotation (invalidates the cached AuthProvider), never-echo-key contract, AuthHeaderForPortal returns "SharedKey ". - handler/api_portal_integration_test.go: full route → handler → service → repo stack against SQLite; ciphertext-in-DB check, response bodies never leak the raw, PUT rotation flips the stored ciphertext. --- .../handler/api_portal_integration_test.go | 577 ++++++++++++++ .../internal/repository/api_portal_test.go | 522 +++++++++++++ .../internal/service/api_portal_test.go | 712 ++++++++++++++++++ 3 files changed, 1811 insertions(+) create mode 100644 platform-api/internal/handler/api_portal_integration_test.go create mode 100644 platform-api/internal/repository/api_portal_test.go create mode 100644 platform-api/internal/service/api_portal_test.go diff --git a/platform-api/internal/handler/api_portal_integration_test.go b/platform-api/internal/handler/api_portal_integration_test.go new file mode 100644 index 0000000000..9527c00a32 --- /dev/null +++ b/platform-api/internal/handler/api_portal_integration_test.go @@ -0,0 +1,577 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed 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. + * + */ + +// Integration tests for the /api-portals handler, covering the full +// route → handler → service → repository stack backed by SQLite. + +package handler + +import ( + "bytes" + "database/sql" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/wso2/api-platform/platform-api/internal/database" + "github.com/wso2/api-platform/platform-api/internal/middleware" + "github.com/wso2/api-platform/platform-api/internal/repository" + "github.com/wso2/api-platform/platform-api/internal/service" + "github.com/wso2/api-platform/platform-api/internal/vault" + + _ "github.com/mattn/go-sqlite3" +) + +// apiPortalTestVault returns a deterministic in-house vault for integration tests. +func apiPortalTestVault(t *testing.T) vault.SecretVault { + t.Helper() + v, err := vault.NewInHouseVault(bytes.Repeat([]byte("t"), 32)) + if err != nil { + t.Fatalf("test vault: %v", err) + } + return v +} + +const apiPortalTestBase = "/api/v0.9/api-portals" +const apiPortalTestOrg = "org-portal-it" +const apiPortalTestUser = "sub-portal-tester" + +// apiPortalTestSharedKey is a syntactically valid 64-char hex value used +// throughout the integration tests. Cryptographically bogus (all-a); its role +// is just to pass service.validateAndEncryptSharedKey's format check so the +// vault.Encrypt path actually runs. +const apiPortalTestSharedKey = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +// setupAPIPortalHandlerEnv brings up the full API-Portal handler stack against a +// fresh SQLite database and seeds the parent organization row the FK requires. +func setupAPIPortalHandlerEnv(t *testing.T) (http.Handler, *database.DB, func()) { + t.Helper() + + dbPath := filepath.Join(t.TempDir(), "api-portal-test.db") + sqlDB, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + db := &database.DB{DB: sqlDB} + + schema, err := os.ReadFile(filepath.Join("..", "database", "schema.sqlite.sql")) + if err != nil { + t.Fatalf("read schema: %v", err) + } + if _, err = db.Exec(string(schema)); err != nil { + t.Fatalf("apply schema: %v", err) + } + if _, err = db.Exec( + `INSERT INTO organizations (uuid, handle, display_name, region, idp_organization_ref_uuid, created_at, updated_at) + VALUES (?, ?, 'Portal Test Org', 'default', 'idp-ref', datetime('now'), datetime('now'))`, + apiPortalTestOrg, "test-org-"+apiPortalTestOrg, + ); err != nil { + t.Fatalf("insert org: %v", err) + } + + portalRepo := repository.NewAPIPortalRepo(db) + orgRepo := repository.NewOrganizationRepo(db) + identityService := service.NewIdentityService(repository.NewUserIdentityMappingRepo(db)) + svc := service.NewAPIPortalService(portalRepo, orgRepo, noopAudit{}, apiPortalTestVault(t), nil, identityService, slog.Default()) + h := NewAPIPortalHandler(svc, identityService, slog.Default()) + + mux := http.NewServeMux() + h.RegisterRoutes(mux) + return middleware.NewTestContextMiddleware(mux), db, func() { _ = sqlDB.Close() } +} + +// apiPortalTestRequest builds a request with the test auth headers set. +func apiPortalTestRequest(t *testing.T, method, path string, body []byte) *http.Request { + t.Helper() + var r *http.Request + if body != nil { + r = httptest.NewRequest(method, path, bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + } else { + r = httptest.NewRequest(method, path, nil) + } + r.Header.Set("X-Test-User", apiPortalTestUser) + r.Header.Set("X-Test-Org", apiPortalTestOrg) + return r +} + +func mustJSON(t *testing.T, v any) []byte { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return b +} + +// Minimal response shapes for decoding — mirror the fields the handler emits. +// Using a dedicated local shape avoids the pointer maze of api.ApiPortalResponse. +// Note the absence of any sharedKey / authType / authConfig field — the response +// schema does not declare them, and a rogue field appearing in the wire body +// would surface as a UnmarshalTypeError on strict decode, but this shape is +// lenient (accepts unknown fields) so we also add explicit assertions below +// that any suspicious response body payload text doesn't contain the raw. +type apiPortalResp struct { + Id string `json:"id"` + Handle string `json:"handle"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Url string `json:"url"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +type apiPortalListResp struct { + Count int `json:"count"` + List []apiPortalResp `json:"list"` + Pagination struct { + Total int `json:"total"` + Offset int `json:"offset"` + Limit int `json:"limit"` + } `json:"pagination"` +} + +type apiPortalErrorResp struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// --- CREATE --- + +func TestAPIPortalHandler_Create_HappyPath(t *testing.T) { + r, _, cleanup := setupAPIPortalHandlerEnv(t) + t.Cleanup(cleanup) + + body := mustJSON(t, map[string]any{ + "name": "Acme Portal", + "handle": "acme", + "url": "https://acme.example.com", + "sharedKey": apiPortalTestSharedKey, + "metadata": map[string]any{"loginEnvironment": "development"}, + }) + req := apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("Create: want 201, got %d: %s", rec.Code, rec.Body.String()) + } + loc := rec.Header().Get("Location") + if !strings.HasSuffix(loc, "/api-portals/acme") { + t.Errorf("Location header wrong: %q", loc) + } + var got apiPortalResp + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Id != "acme" || got.Handle != "acme" || got.Name != "Acme Portal" || + got.Url != "https://acme.example.com" { + t.Errorf("response fields wrong: %+v", got) + } + if got.Metadata["loginEnvironment"] != "development" { + t.Errorf("metadata round-trip failed: %v", got.Metadata) + } + // Belt-and-suspenders: the raw sharedKey MUST NOT appear anywhere in the + // response body — not as a field, not embedded in another string, not + // leaked via any error message. + if strings.Contains(rec.Body.String(), apiPortalTestSharedKey) { + t.Errorf("raw sharedKey leaked in Create response body: %s", rec.Body.String()) + } +} + +func TestAPIPortalHandler_Create_SharedKey_EncryptedInDB(t *testing.T) { + r, db, cleanup := setupAPIPortalHandlerEnv(t) + t.Cleanup(cleanup) + + body := mustJSON(t, map[string]any{ + "name": "Acme", + "handle": "acme-enc", + "url": "https://acme.example.com", + "sharedKey": apiPortalTestSharedKey, + }) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body)) + if rec.Code != http.StatusCreated { + t.Fatalf("Create: want 201, got %d: %s", rec.Code, rec.Body.String()) + } + + // DB must NOT contain the plaintext sharedKey. + var stored []byte + if err := db.QueryRow(`SELECT internal_auth_key FROM api_portals WHERE handle = 'acme-enc'`).Scan(&stored); err != nil { + t.Fatalf("query internal_auth_key: %v", err) + } + if len(stored) == 0 { + t.Fatal("internal_auth_key is empty") + } + if bytes.Contains(stored, []byte(apiPortalTestSharedKey)) { + t.Errorf("plaintext sharedKey found in internal_auth_key blob: % x", stored) + } +} + +func TestAPIPortalHandler_Create_MissingName(t *testing.T) { + r, _, cleanup := setupAPIPortalHandlerEnv(t) + t.Cleanup(cleanup) + + body := mustJSON(t, map[string]any{ + "handle": "acme", + "url": "https://acme.example.com", + "sharedKey": apiPortalTestSharedKey, + }) + req := apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400 for missing name, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestAPIPortalHandler_Create_MissingURL(t *testing.T) { + r, _, cleanup := setupAPIPortalHandlerEnv(t) + t.Cleanup(cleanup) + + body := mustJSON(t, map[string]any{ + "name": "Acme Portal", + "handle": "acme-nourl", + "sharedKey": apiPortalTestSharedKey, + }) + req := apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("Create: want 400 for missing url, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestAPIPortalHandler_Create_MissingSharedKey(t *testing.T) { + r, _, cleanup := setupAPIPortalHandlerEnv(t) + t.Cleanup(cleanup) + + body := mustJSON(t, map[string]any{ + "name": "Acme", + "handle": "acme-nokey", + "url": "https://acme.example.com", + }) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body)) + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400 for missing sharedKey, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestAPIPortalHandler_Create_HandleConflict(t *testing.T) { + r, _, cleanup := setupAPIPortalHandlerEnv(t) + t.Cleanup(cleanup) + + body := mustJSON(t, map[string]any{ + "name": "a", + "handle": "dup", + "url": "https://a.example.com", + "sharedKey": apiPortalTestSharedKey, + }) + req := apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("first Create: want 201, got %d: %s", rec.Code, rec.Body.String()) + } + + // Second POST with the same handle must be 409. + req2 := apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body) + rec2 := httptest.NewRecorder() + r.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusConflict { + t.Fatalf("duplicate Create: want 409, got %d: %s", rec2.Code, rec2.Body.String()) + } + var errBody apiPortalErrorResp + if err := json.Unmarshal(rec2.Body.Bytes(), &errBody); err != nil { + t.Fatalf("decode error body: %v", err) + } + if errBody.Code != "API_PORTAL_EXISTS" { + t.Errorf("error code: want API_PORTAL_EXISTS, got %q", errBody.Code) + } +} + +func TestAPIPortalHandler_Create_MissingOrg(t *testing.T) { + r, _, cleanup := setupAPIPortalHandlerEnv(t) + t.Cleanup(cleanup) + + body := mustJSON(t, map[string]any{ + "name": "a", + "handle": "acme", + "url": "https://acme.example.com", + "sharedKey": apiPortalTestSharedKey, + }) + // Deliberately DO NOT set X-Test-Org; expect 401 from the handler's org guard. + req := httptest.NewRequest(http.MethodPost, apiPortalTestBase, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Test-User", apiPortalTestUser) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("want 401 for missing org context, got %d: %s", rec.Code, rec.Body.String()) + } +} + +// --- GET (single) --- + +func TestAPIPortalHandler_Get_HappyPath(t *testing.T) { + r, _, cleanup := setupAPIPortalHandlerEnv(t) + t.Cleanup(cleanup) + + // Seed via POST. + body := mustJSON(t, map[string]any{ + "name": "Acme", + "handle": "acme", + "url": "https://acme.example.com", + "sharedKey": apiPortalTestSharedKey, + }) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body)) + if rec.Code != http.StatusCreated { + t.Fatalf("seed Create failed: %d %s", rec.Code, rec.Body.String()) + } + + getRec := httptest.NewRecorder() + r.ServeHTTP(getRec, apiPortalTestRequest(t, http.MethodGet, apiPortalTestBase+"/acme", nil)) + if getRec.Code != http.StatusOK { + t.Fatalf("Get: want 200, got %d: %s", getRec.Code, getRec.Body.String()) + } + var got apiPortalResp + if err := json.Unmarshal(getRec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Handle != "acme" || got.Name != "Acme" { + t.Errorf("Get response wrong: %+v", got) + } + // GET must never surface the raw sharedKey — belt-and-suspenders check + // on top of the response DTO having no sharedKey field. + if strings.Contains(getRec.Body.String(), apiPortalTestSharedKey) { + t.Errorf("raw sharedKey leaked in Get response body: %s", getRec.Body.String()) + } +} + +func TestAPIPortalHandler_Get_NotFound(t *testing.T) { + r, _, cleanup := setupAPIPortalHandlerEnv(t) + t.Cleanup(cleanup) + + rec := httptest.NewRecorder() + r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodGet, apiPortalTestBase+"/ghost", nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("Get missing: want 404, got %d: %s", rec.Code, rec.Body.String()) + } + var errBody apiPortalErrorResp + if err := json.Unmarshal(rec.Body.Bytes(), &errBody); err != nil { + t.Fatalf("decode error body: %v", err) + } + if errBody.Code != "API_PORTAL_NOT_FOUND" { + t.Errorf("error code: want API_PORTAL_NOT_FOUND, got %q", errBody.Code) + } +} + +// --- LIST --- + +func TestAPIPortalHandler_List_HappyPath(t *testing.T) { + r, _, cleanup := setupAPIPortalHandlerEnv(t) + t.Cleanup(cleanup) + + // Seed 3 portals. + for _, h := range []string{"one", "two", "three"} { + body := mustJSON(t, map[string]any{ + "name": "P " + h, + "handle": h, + "url": "https://" + h + ".example.com", + "sharedKey": apiPortalTestSharedKey, + }) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body)) + if rec.Code != http.StatusCreated { + t.Fatalf("seed %s: %d %s", h, rec.Code, rec.Body.String()) + } + } + + rec := httptest.NewRecorder() + r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodGet, apiPortalTestBase, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("List: want 200, got %d: %s", rec.Code, rec.Body.String()) + } + var got apiPortalListResp + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Count != 3 || got.Pagination.Total != 3 || len(got.List) != 3 { + t.Errorf("counts wrong: %+v", got) + } + if got.Pagination.Limit != 20 { + t.Errorf("default limit: want 20, got %d", got.Pagination.Limit) + } + // List responses must never surface any sharedKey. + if strings.Contains(rec.Body.String(), apiPortalTestSharedKey) { + t.Errorf("raw sharedKey leaked in List response body: %s", rec.Body.String()) + } +} + +// --- UPDATE --- + +func TestAPIPortalHandler_Update_HappyPath(t *testing.T) { + r, _, cleanup := setupAPIPortalHandlerEnv(t) + t.Cleanup(cleanup) + + // Seed. + body := mustJSON(t, map[string]any{ + "name": "old", + "handle": "acme", + "url": "https://acme.example.com", + "sharedKey": apiPortalTestSharedKey, + }) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body)) + if rec.Code != http.StatusCreated { + t.Fatalf("seed: %d %s", rec.Code, rec.Body.String()) + } + + // Update name — no sharedKey → InternalAuthKey untouched (rotation path + // tested separately). + patch := mustJSON(t, map[string]any{ + "name": "new", + "description": "an updated portal", + }) + putRec := httptest.NewRecorder() + r.ServeHTTP(putRec, apiPortalTestRequest(t, http.MethodPut, apiPortalTestBase+"/acme", patch)) + if putRec.Code != http.StatusOK { + t.Fatalf("Update: want 200, got %d: %s", putRec.Code, putRec.Body.String()) + } + var got apiPortalResp + if err := json.Unmarshal(putRec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Name != "new" { + t.Errorf("mutable fields not applied: %+v", got) + } + if got.Handle != "acme" { + t.Errorf("handle mutated: %q", got.Handle) + } + if strings.Contains(putRec.Body.String(), apiPortalTestSharedKey) { + t.Errorf("raw sharedKey leaked in Update response body: %s", putRec.Body.String()) + } +} + +func TestAPIPortalHandler_Update_RotateSharedKey(t *testing.T) { + // PUT with a fresh sharedKey rotates the stored ciphertext. Verify + // end-to-end that (1) the response is 200 with no key material, (2) the + // DB blob changed from what Create wrote, (3) the new plaintext is not + // visible in the DB blob. + r, db, cleanup := setupAPIPortalHandlerEnv(t) + t.Cleanup(cleanup) + + body := mustJSON(t, map[string]any{ + "name": "Acme", + "handle": "acme", + "url": "https://acme.example.com", + "sharedKey": apiPortalTestSharedKey, + }) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body)) + if rec.Code != http.StatusCreated { + t.Fatalf("seed: %d %s", rec.Code, rec.Body.String()) + } + var beforeKey []byte + if err := db.QueryRow(`SELECT internal_auth_key FROM api_portals WHERE handle = 'acme'`).Scan(&beforeKey); err != nil { + t.Fatalf("query internal_auth_key: %v", err) + } + + newSharedKey := strings.Repeat("b", 64) + patch := mustJSON(t, map[string]any{"sharedKey": newSharedKey}) + putRec := httptest.NewRecorder() + r.ServeHTTP(putRec, apiPortalTestRequest(t, http.MethodPut, apiPortalTestBase+"/acme", patch)) + if putRec.Code != http.StatusOK { + t.Fatalf("Update: want 200, got %d: %s", putRec.Code, putRec.Body.String()) + } + if strings.Contains(putRec.Body.String(), newSharedKey) { + t.Errorf("rotated sharedKey leaked in Update response: %s", putRec.Body.String()) + } + + var afterKey []byte + if err := db.QueryRow(`SELECT internal_auth_key FROM api_portals WHERE handle = 'acme'`).Scan(&afterKey); err != nil { + t.Fatalf("query internal_auth_key: %v", err) + } + if bytes.Equal(beforeKey, afterKey) { + t.Error("internal_auth_key not rotated in DB") + } + if bytes.Contains(afterKey, []byte(newSharedKey)) { + t.Errorf("plaintext rotated sharedKey found in DB blob: % x", afterKey) + } +} + +func TestAPIPortalHandler_Update_NotFound(t *testing.T) { + r, _, cleanup := setupAPIPortalHandlerEnv(t) + t.Cleanup(cleanup) + + patch := mustJSON(t, map[string]any{"name": "x"}) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodPut, apiPortalTestBase+"/ghost", patch)) + if rec.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d: %s", rec.Code, rec.Body.String()) + } +} + +// --- DELETE --- + +func TestAPIPortalHandler_Delete_HappyPath(t *testing.T) { + r, _, cleanup := setupAPIPortalHandlerEnv(t) + t.Cleanup(cleanup) + + body := mustJSON(t, map[string]any{ + "name": "x", + "handle": "gone", + "url": "https://gone.example.com", + "sharedKey": apiPortalTestSharedKey, + }) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodPost, apiPortalTestBase, body)) + if rec.Code != http.StatusCreated { + t.Fatalf("seed: %d %s", rec.Code, rec.Body.String()) + } + + delRec := httptest.NewRecorder() + r.ServeHTTP(delRec, apiPortalTestRequest(t, http.MethodDelete, apiPortalTestBase+"/gone", nil)) + if delRec.Code != http.StatusNoContent { + t.Fatalf("Delete: want 204, got %d: %s", delRec.Code, delRec.Body.String()) + } + + // Subsequent Get is 404. + getRec := httptest.NewRecorder() + r.ServeHTTP(getRec, apiPortalTestRequest(t, http.MethodGet, apiPortalTestBase+"/gone", nil)) + if getRec.Code != http.StatusNotFound { + t.Fatalf("Get after Delete: want 404, got %d", getRec.Code) + } +} + +func TestAPIPortalHandler_Delete_NotFound(t *testing.T) { + r, _, cleanup := setupAPIPortalHandlerEnv(t) + t.Cleanup(cleanup) + + rec := httptest.NewRecorder() + r.ServeHTTP(rec, apiPortalTestRequest(t, http.MethodDelete, apiPortalTestBase+"/ghost", nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("Delete missing: want 404, got %d: %s", rec.Code, rec.Body.String()) + } +} diff --git a/platform-api/internal/repository/api_portal_test.go b/platform-api/internal/repository/api_portal_test.go new file mode 100644 index 0000000000..a0d8009184 --- /dev/null +++ b/platform-api/internal/repository/api_portal_test.go @@ -0,0 +1,522 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed 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 repository + +import ( + "bytes" + "strings" + "testing" + "time" + + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/database" + "github.com/wso2/api-platform/platform-api/internal/model" +) + +// createTestAPIPortalOrg inserts the organization row api_portals references via its FK. +// The organizations table has no other prerequisite so this is a single INSERT. +func createTestAPIPortalOrg(t *testing.T, db *database.DB, orgUUID string) { + t.Helper() + q := ` + INSERT INTO organizations (uuid, handle, display_name, region, idp_organization_ref_uuid, created_at, updated_at) + VALUES (?, ?, ?, 'default', 'idp-ref', datetime('now'), datetime('now')) + ` + if _, err := db.Exec(q, orgUUID, "test-org-"+orgUUID, "Test Org"); err != nil { + t.Fatalf("failed to insert test organization: %v", err) + } +} + +// newTestAPIPortal returns a valid *model.APIPortal with sensible defaults. +// Individual tests override the fields they care about. InternalAuthKey is +// populated with a non-empty byte slice because the column is NOT NULL; test +// bytes stand in for what would be AES-GCM ciphertext produced by +// service.validateAndEncryptSharedKey in the live code. +func newTestAPIPortal(uuid, orgUUID, handle string) *model.APIPortal { + return &model.APIPortal{ + ID: uuid, + OrganizationID: orgUUID, + Handle: handle, + Name: "Portal " + handle, + Description: "test portal", + URL: "https://" + handle + ".example.com", + Status: constants.APIPortalStatusPending, + InternalAuthKey: []byte("test-ciphertext-" + handle), + CreatedBy: "tester", + UpdatedBy: "tester", + } +} + +func TestAPIPortalRepo_CreateAndGet(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgUUID = "org-portal-crud" + createTestAPIPortalOrg(t, db, orgUUID) + + repo := NewAPIPortalRepo(db) + portal := newTestAPIPortal("portal-001", orgUUID, "acme") + if err := repo.Create(portal); err != nil { + t.Fatalf("Create: %v", err) + } + + // Get by UUID. + got, err := repo.GetByUUID(portal.ID, orgUUID) + if err != nil { + t.Fatalf("GetByUUID: %v", err) + } + if got == nil { + t.Fatal("GetByUUID: expected row, got nil") + } + if got.Handle != portal.Handle || got.Name != portal.Name || got.URL != portal.URL { + t.Errorf("GetByUUID: field mismatch; got %+v", got) + } + if !bytes.Equal(got.InternalAuthKey, portal.InternalAuthKey) { + t.Errorf("InternalAuthKey not round-tripped; want %q got %q", + portal.InternalAuthKey, got.InternalAuthKey) + } + + // Get by handle. + got2, err := repo.GetByHandleAndOrgID(portal.Handle, orgUUID) + if err != nil { + t.Fatalf("GetByHandleAndOrgID: %v", err) + } + if got2 == nil || got2.ID != portal.ID { + t.Errorf("GetByHandleAndOrgID mismatch; got %+v", got2) + } +} + +func TestAPIPortalRepo_Create_SetsDefaults(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgUUID = "org-portal-defaults" + createTestAPIPortalOrg(t, db, orgUUID) + + repo := NewAPIPortalRepo(db) + portal := newTestAPIPortal("portal-defaults", orgUUID, "defaults") + // Explicitly leave timestamps zero; expect Create to populate them. + portal.CreatedAt = time.Time{} + portal.UpdatedAt = time.Time{} + + before := time.Now().UTC().Add(-time.Second) + if err := repo.Create(portal); err != nil { + t.Fatalf("Create: %v", err) + } + after := time.Now().UTC().Add(time.Second) + + if portal.CreatedAt.Before(before) || portal.CreatedAt.After(after) { + t.Errorf("CreatedAt not set to ~now: got %v", portal.CreatedAt) + } + if portal.UpdatedAt.Before(before) || portal.UpdatedAt.After(after) { + t.Errorf("UpdatedAt not set to ~now: got %v", portal.UpdatedAt) + } +} + +func TestAPIPortalRepo_Create_MetadataRoundTrip_Nil(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgUUID = "org-portal-meta-nil" + createTestAPIPortalOrg(t, db, orgUUID) + + repo := NewAPIPortalRepo(db) + portal := newTestAPIPortal("portal-meta-nil", orgUUID, "meta-nil") + portal.Metadata = nil // stored as SQL NULL, read back as nil map + + if err := repo.Create(portal); err != nil { + t.Fatalf("Create: %v", err) + } + got, err := repo.GetByUUID(portal.ID, orgUUID) + if err != nil { + t.Fatalf("GetByUUID: %v", err) + } + if got.Metadata != nil { + t.Errorf("Metadata: want nil after round-trip (column is nullable and marshalAPIPortalBlob returns nil for empty maps); got %v", got.Metadata) + } +} + +func TestAPIPortalRepo_Create_MetadataRoundTrip_Populated(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgUUID = "org-portal-meta-full" + createTestAPIPortalOrg(t, db, orgUUID) + + repo := NewAPIPortalRepo(db) + portal := newTestAPIPortal("portal-meta-full", orgUUID, "meta-full") + portal.Metadata = map[string]interface{}{ + "loginEnvironment": "development", + "tags": []interface{}{"beta", "internal"}, + } + + if err := repo.Create(portal); err != nil { + t.Fatalf("Create: %v", err) + } + got, err := repo.GetByUUID(portal.ID, orgUUID) + if err != nil { + t.Fatalf("GetByUUID: %v", err) + } + if got.Metadata["loginEnvironment"] != "development" { + t.Errorf("loginEnvironment round-trip failed; got %v", got.Metadata["loginEnvironment"]) + } + tags, ok := got.Metadata["tags"].([]interface{}) + if !ok || len(tags) != 2 || tags[0] != "beta" || tags[1] != "internal" { + t.Errorf("tags round-trip failed; got %v", got.Metadata["tags"]) + } +} + +func TestAPIPortalRepo_Create_InternalAuthKeyRoundTrip(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgUUID = "org-portal-key-rt" + createTestAPIPortalOrg(t, db, orgUUID) + + repo := NewAPIPortalRepo(db) + // Simulate what service.validateAndEncryptSharedKey produces: opaque bytes + // that are neither valid UTF-8 nor a stable text encoding. The column is + // BYTEA / BLOB / VARBINARY and must survive verbatim. + binaryCiphertext := []byte{0x00, 0xff, 0x10, 0x7f, 0x80, 0xaa, 0x55, 0xde, 0xad, 0xbe, 0xef} + + portal := newTestAPIPortal("portal-key-rt", orgUUID, "key-rt") + portal.InternalAuthKey = binaryCiphertext + + if err := repo.Create(portal); err != nil { + t.Fatalf("Create: %v", err) + } + got, err := repo.GetByUUID(portal.ID, orgUUID) + if err != nil { + t.Fatalf("GetByUUID: %v", err) + } + if !bytes.Equal(got.InternalAuthKey, binaryCiphertext) { + t.Errorf("InternalAuthKey bytes corrupted through round-trip;\n want % x\n got % x", binaryCiphertext, got.InternalAuthKey) + } +} + +func TestAPIPortalRepo_Create_DuplicateHandle(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgUUID = "org-portal-dup" + createTestAPIPortalOrg(t, db, orgUUID) + + repo := NewAPIPortalRepo(db) + if err := repo.Create(newTestAPIPortal("portal-dup-1", orgUUID, "dup")); err != nil { + t.Fatalf("first Create: %v", err) + } + err := repo.Create(newTestAPIPortal("portal-dup-2", orgUUID, "dup")) + if err == nil { + t.Fatal("expected duplicate handle to fail, got nil") + } + if !IsUniqueViolation(err) { + t.Errorf("expected unique-constraint violation, got %v", err) + } +} + +func TestAPIPortalRepo_Get_NotFound(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgUUID = "org-portal-nf" + createTestAPIPortalOrg(t, db, orgUUID) + + repo := NewAPIPortalRepo(db) + got, err := repo.GetByUUID("does-not-exist", orgUUID) + if err != nil { + t.Fatalf("GetByUUID: unexpected error: %v", err) + } + if got != nil { + t.Errorf("GetByUUID: expected nil for missing row, got %+v", got) + } + got2, err := repo.GetByHandleAndOrgID("no-such-handle", orgUUID) + if err != nil { + t.Fatalf("GetByHandleAndOrgID: unexpected error: %v", err) + } + if got2 != nil { + t.Errorf("GetByHandleAndOrgID: expected nil for missing row, got %+v", got2) + } +} + +func TestAPIPortalRepo_Get_CrossOrgIsolation(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgA = "org-portal-a" + const orgB = "org-portal-b" + createTestAPIPortalOrg(t, db, orgA) + createTestAPIPortalOrg(t, db, orgB) + + repo := NewAPIPortalRepo(db) + if err := repo.Create(newTestAPIPortal("portal-a", orgA, "shared-handle")); err != nil { + t.Fatalf("Create A: %v", err) + } + if err := repo.Create(newTestAPIPortal("portal-b", orgB, "shared-handle")); err != nil { + t.Fatalf("Create B (different org, same handle allowed): %v", err) + } + // A's portal-a must not be visible when querying org B. + got, err := repo.GetByUUID("portal-a", orgB) + if err != nil { + t.Fatalf("GetByUUID cross-org: %v", err) + } + if got != nil { + t.Errorf("cross-org leak: got %+v", got) + } +} + +func TestAPIPortalRepo_ListPaginated(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgUUID = "org-portal-list" + createTestAPIPortalOrg(t, db, orgUUID) + + repo := NewAPIPortalRepo(db) + // Insert 5 portals with staggered createdAt to make ordering deterministic. + for i, handle := range []string{"aa", "bb", "cc", "dd", "ee"} { + p := newTestAPIPortal("portal-"+handle, orgUUID, handle) + if err := repo.Create(p); err != nil { + t.Fatalf("Create %s: %v", handle, err) + } + // Nudge each row's created_at forward so DESC ordering is stable. + p.CreatedAt = time.Now().UTC().Add(time.Duration(i) * time.Millisecond) + if _, err := db.Exec(`UPDATE api_portals SET created_at = ? WHERE uuid = ?`, p.CreatedAt, p.ID); err != nil { + t.Fatalf("nudge created_at: %v", err) + } + } + + // Page 1: limit 2 → newest first ("ee", "dd"). + page1, err := repo.ListPaginated(orgUUID, ListOptions{Limit: 2, Offset: 0}) + if err != nil { + t.Fatalf("ListPaginated page 1: %v", err) + } + if len(page1) != 2 { + t.Fatalf("page 1 size: want 2, got %d", len(page1)) + } + if page1[0].Handle != "ee" || page1[1].Handle != "dd" { + t.Errorf("page 1 order: got %s, %s", page1[0].Handle, page1[1].Handle) + } + + // Page 2: offset 2, limit 2 → "cc", "bb". + page2, err := repo.ListPaginated(orgUUID, ListOptions{Limit: 2, Offset: 2}) + if err != nil { + t.Fatalf("ListPaginated page 2: %v", err) + } + if len(page2) != 2 || page2[0].Handle != "cc" || page2[1].Handle != "bb" { + t.Errorf("page 2: %+v", page2) + } + + // Count without filter. + total, err := repo.Count(orgUUID, "") + if err != nil { + t.Fatalf("Count: %v", err) + } + if total != 5 { + t.Errorf("Count: want 5, got %d", total) + } +} + +func TestAPIPortalRepo_ListPaginated_Search(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgUUID = "org-portal-search" + createTestAPIPortalOrg(t, db, orgUUID) + + repo := NewAPIPortalRepo(db) + for _, h := range []string{"acme-dev", "acme-prod", "other-portal"} { + if err := repo.Create(newTestAPIPortal("portal-"+h, orgUUID, h)); err != nil { + t.Fatalf("Create %s: %v", h, err) + } + } + got, err := repo.ListPaginated(orgUUID, ListOptions{Limit: 10, Offset: 0, Search: "acme"}) + if err != nil { + t.Fatalf("ListPaginated: %v", err) + } + if len(got) != 2 { + t.Errorf("want 2 acme results, got %d: %+v", len(got), got) + } +} + +func TestAPIPortalRepo_Update(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgUUID = "org-portal-upd" + createTestAPIPortalOrg(t, db, orgUUID) + + repo := NewAPIPortalRepo(db) + portal := newTestAPIPortal("portal-upd", orgUUID, "upd") + if err := repo.Create(portal); err != nil { + t.Fatalf("Create: %v", err) + } + origCreatedAt := portal.CreatedAt + + // Mutate every whitelisted field + attempt to mutate an immutable one (handle). + // OrganizationID is left untouched because the UPDATE uses it in the WHERE + // clause for org isolation; cross-org attempts are covered by + // TestAPIPortalRepo_Update_CrossOrgIsolation. + portal.Name = "Renamed" + portal.Description = "new description" + portal.URL = "https://renamed.example.com" + portal.Status = constants.APIPortalStatusActive + portal.InternalAuthKey = []byte("rotated-ciphertext") + portal.Metadata = map[string]interface{}{"loginEnvironment": "production"} + portal.UpdatedBy = "editor" + portal.Handle = "attempted-rename" // immutable — must NOT stick + + if err := repo.Update(portal); err != nil { + t.Fatalf("Update: %v", err) + } + + got, err := repo.GetByUUID("portal-upd", orgUUID) + if err != nil { + t.Fatalf("GetByUUID: %v", err) + } + if got == nil { + t.Fatal("row disappeared after Update") + } + if got.Name != "Renamed" || got.Description != "new description" || + got.URL != "https://renamed.example.com" || + got.Status != constants.APIPortalStatusActive || + got.UpdatedBy != "editor" { + t.Errorf("mutable fields not persisted; got %+v", got) + } + if !bytes.Equal(got.InternalAuthKey, []byte("rotated-ciphertext")) { + t.Errorf("InternalAuthKey not persisted; want %q got %q", + "rotated-ciphertext", got.InternalAuthKey) + } + if got.Metadata["loginEnvironment"] != "production" { + t.Errorf("metadata not persisted; got %v", got.Metadata) + } + if got.Handle != "upd" { + t.Errorf("handle was mutated despite being immutable; want %q, got %q", "upd", got.Handle) + } + if !got.CreatedAt.Equal(origCreatedAt) { + t.Errorf("created_at was touched; before %v, after %v", origCreatedAt, got.CreatedAt) + } +} + +func TestAPIPortalRepo_Update_NotFound(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgUUID = "org-portal-upd-nf" + createTestAPIPortalOrg(t, db, orgUUID) + + repo := NewAPIPortalRepo(db) + err := repo.Update(newTestAPIPortal("ghost", orgUUID, "ghost")) + if err == nil { + t.Fatal("expected Update on missing row to error") + } + if !strings.Contains(err.Error(), "api portal not found") { + t.Errorf("want error containing %q, got %q", "api portal not found", err.Error()) + } +} + +func TestAPIPortalRepo_Update_CrossOrgIsolation(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgA = "org-portal-upd-a" + const orgB = "org-portal-upd-b" + createTestAPIPortalOrg(t, db, orgA) + createTestAPIPortalOrg(t, db, orgB) + + repo := NewAPIPortalRepo(db) + if err := repo.Create(newTestAPIPortal("portal-a", orgA, "iso")); err != nil { + t.Fatalf("Create: %v", err) + } + // Attempt to update A's portal claiming to be in org B — must be rejected as not-found. + portal := newTestAPIPortal("portal-a", orgB, "iso") + portal.Name = "hijack" + err := repo.Update(portal) + if err == nil { + t.Fatal("expected Update with wrong org to error as not-found") + } + if !strings.Contains(err.Error(), "api portal not found") { + t.Errorf("want error containing %q, got %q", "api portal not found", err.Error()) + } +} + +func TestAPIPortalRepo_Delete(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgUUID = "org-portal-del" + createTestAPIPortalOrg(t, db, orgUUID) + + repo := NewAPIPortalRepo(db) + portal := newTestAPIPortal("portal-del", orgUUID, "del") + if err := repo.Create(portal); err != nil { + t.Fatalf("Create: %v", err) + } + if err := repo.Delete(portal.ID, orgUUID); err != nil { + t.Fatalf("Delete: %v", err) + } + got, err := repo.GetByUUID(portal.ID, orgUUID) + if err != nil { + t.Fatalf("GetByUUID after Delete: %v", err) + } + if got != nil { + t.Errorf("row still present after Delete: %+v", got) + } +} + +func TestAPIPortalRepo_Delete_NotFound(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgUUID = "org-portal-del-nf" + createTestAPIPortalOrg(t, db, orgUUID) + + repo := NewAPIPortalRepo(db) + err := repo.Delete("ghost", orgUUID) + if err == nil { + t.Fatal("expected Delete on missing row to error") + } + if !strings.Contains(err.Error(), "api portal not found") { + t.Errorf("want error containing %q, got %q", "api portal not found", err.Error()) + } +} + +func TestAPIPortalRepo_Exists(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + const orgUUID = "org-portal-exists" + createTestAPIPortalOrg(t, db, orgUUID) + + repo := NewAPIPortalRepo(db) + ok, err := repo.Exists("nope", orgUUID) + if err != nil { + t.Fatalf("Exists: %v", err) + } + if ok { + t.Error("Exists: expected false for missing row") + } + if err := repo.Create(newTestAPIPortal("portal-e", orgUUID, "here")); err != nil { + t.Fatalf("Create: %v", err) + } + ok, err = repo.Exists("here", orgUUID) + if err != nil { + t.Fatalf("Exists: %v", err) + } + if !ok { + t.Error("Exists: expected true for existing row") + } +} diff --git a/platform-api/internal/service/api_portal_test.go b/platform-api/internal/service/api_portal_test.go new file mode 100644 index 0000000000..00fcca12c0 --- /dev/null +++ b/platform-api/internal/service/api_portal_test.go @@ -0,0 +1,712 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed 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 service + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/model" + "github.com/wso2/api-platform/platform-api/internal/repository" + "github.com/wso2/api-platform/platform-api/internal/vault" +) + +// testSharedKeyHex is a valid 64-char hex value the service's +// validateAndEncryptSharedKey accepts. Cryptographically bogus (all-a) but +// syntactically correct — matches `^[0-9a-fA-F]{64}$`. +const testSharedKeyHex = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +// newTestVault returns a real InHouseVault seeded with a deterministic 32-byte +// key. Using the real implementation (rather than a fake) validates the +// encrypt/decrypt round-trip actually works. +func newTestVault(t *testing.T) vault.SecretVault { + t.Helper() + v, err := vault.NewInHouseVault(bytes.Repeat([]byte("t"), 32)) + if err != nil { + t.Fatalf("test vault: %v", err) + } + return v +} + +// --- mocks --- +// Each mock embeds the interface so unimplemented methods panic on invocation, +// making it obvious when a test exercises an unstubbed code path. + +type mockAPIPortalRepository struct { + repository.APIPortalRepository + + existsResult bool + existsErr error + + createErr error + createReturnUnique bool // if true, Create returns a canned unique-violation + createCapturedInput *model.APIPortal + + getResult *model.APIPortal + getErr error + + listResult []*model.APIPortal + listErr error + + countResult int + countErr error + + updateErr error + updateCapturedInput *model.APIPortal + + deleteCalledWith [2]string + deleteErr error +} + +// canned unique-violation error — matches IsUniqueViolation's SQLite substring. +var errCannedUnique = errors.New("UNIQUE constraint failed: api_portals.handle") + +func (m *mockAPIPortalRepository) Exists(handle, orgUUID string) (bool, error) { + return m.existsResult, m.existsErr +} + +func (m *mockAPIPortalRepository) Create(portal *model.APIPortal) error { + m.createCapturedInput = portal + if m.createReturnUnique { + return errCannedUnique + } + return m.createErr +} + +func (m *mockAPIPortalRepository) GetByHandleAndOrgID(handle, orgUUID string) (*model.APIPortal, error) { + return m.getResult, m.getErr +} + +func (m *mockAPIPortalRepository) ListPaginated(orgUUID string, opts repository.ListOptions) ([]*model.APIPortal, error) { + return m.listResult, m.listErr +} + +func (m *mockAPIPortalRepository) Count(orgUUID string, search string) (int, error) { + return m.countResult, m.countErr +} + +func (m *mockAPIPortalRepository) Update(portal *model.APIPortal) error { + m.updateCapturedInput = portal + return m.updateErr +} + +func (m *mockAPIPortalRepository) Delete(portalID, orgUUID string) error { + m.deleteCalledWith = [2]string{portalID, orgUUID} + return m.deleteErr +} + +type mockAPIPortalOrgRepository struct { + repository.OrganizationRepository + result *model.Organization + err error +} + +func (m *mockAPIPortalOrgRepository) GetOrganizationByUUID(uuid string) (*model.Organization, error) { + return m.result, m.err +} + +type mockAPIPortalAuditRepository struct { + repository.AuditRepository + records []auditRecord +} + +type auditRecord struct { + action string + resourceUUID string + resourceType string + orgUUID string + performedBy string +} + +func (m *mockAPIPortalAuditRepository) Record(action, resourceUUID, resourceType, orgUUID, performedBy string) error { + m.records = append(m.records, auditRecord{action, resourceUUID, resourceType, orgUUID, performedBy}) + return nil +} + +// newTestAPIPortalService wires the three mocks together with a real +// InHouseVault. identity + slogger are nil because the service does not invoke +// them. authRegistry is nil for pure-CRUD tests; a real registry is wired +// only in the tests that exercise AuthHeaderForPortal / Invalidate paths +// (see api_portal_auth_test scenarios below). +func newTestAPIPortalService(t *testing.T, + portalRepo repository.APIPortalRepository, + orgRepo repository.OrganizationRepository, + auditRepo repository.AuditRepository, +) *APIPortalService { + return NewAPIPortalService(portalRepo, orgRepo, auditRepo, newTestVault(t), nil, nil, nil) +} + +func apiPortalStrPtr(s string) *string { return &s } + +// --- test-DTO builders --- + +type testCreateReq struct { + Handle string + Name string + Description string + URL string + SharedKey string // 64-hex; test uses testSharedKeyHex unless overridden + Metadata map[string]interface{} +} + +func (r testCreateReq) build() *api.CreateApiPortalRequest { + sk := r.SharedKey + out := &api.CreateApiPortalRequest{ + Handle: r.Handle, + Name: r.Name, + Url: r.URL, + SharedKey: &sk, + } + if r.Description != "" { + d := r.Description + out.Description = &d + } + if r.Metadata != nil { + m := api.ApiPortalMetadata(r.Metadata) + out.Metadata = &m + } + return out +} + +type testUpdateReq struct { + Name *string + Description *string + URL *string + SharedKey *string + Metadata map[string]interface{} +} + +func (r testUpdateReq) build() *api.UpdateApiPortalRequest { + out := &api.UpdateApiPortalRequest{ + Name: r.Name, + Description: r.Description, + Url: r.URL, + SharedKey: r.SharedKey, + } + if r.Metadata != nil { + m := api.ApiPortalMetadata(r.Metadata) + out.Metadata = &m + } + return out +} + +// --- Create tests --- + +func TestAPIPortalService_CreateAPIPortal_HappyPath(t *testing.T) { + portalRepo := &mockAPIPortalRepository{} + orgRepo := &mockAPIPortalOrgRepository{result: &model.Organization{}} + auditRepo := &mockAPIPortalAuditRepository{} + svc := newTestAPIPortalService(t, portalRepo, orgRepo, auditRepo) + + req := testCreateReq{ + Handle: "acme", + Name: "Acme Portal", + Description: "test", + URL: "https://acme.example.com", + SharedKey: testSharedKeyHex, + Metadata: map[string]interface{}{"loginEnvironment": "development"}, + } + got, err := svc.CreateAPIPortal(req.build(), "org-1", "user-1") + if err != nil { + t.Fatalf("CreateAPIPortal: %v", err) + } + if got == nil || derefStr(got.Handle) != "acme" || got.Name != "Acme Portal" { + t.Errorf("returned portal wrong shape: %+v", got) + } + if portalRepo.createCapturedInput == nil { + t.Fatal("repository Create not called") + } + // OSS registers a portal that's already running; status is always + // active from create, and is not exposed on the wire. + if portalRepo.createCapturedInput.Status != constants.APIPortalStatusActive { + t.Errorf("default status: want active, got %q", portalRepo.createCapturedInput.Status) + } + if portalRepo.createCapturedInput.ID == "" { + t.Error("expected generated UUID, got empty") + } + if portalRepo.createCapturedInput.CreatedBy != "user-1" || portalRepo.createCapturedInput.UpdatedBy != "user-1" { + t.Errorf("actor not populated: createdBy=%q updatedBy=%q", + portalRepo.createCapturedInput.CreatedBy, portalRepo.createCapturedInput.UpdatedBy) + } + // InternalAuthKey holds the AES-GCM ciphertext of the sharedKey. Cannot + // compare bytes directly (nonce is random per encrypt), but non-empty + // bytes confirm the vault.Encrypt path ran. + if len(portalRepo.createCapturedInput.InternalAuthKey) == 0 { + t.Error("InternalAuthKey empty; expected encrypted ciphertext") + } + if len(auditRepo.records) != 1 || auditRepo.records[0].action != "CREATE" { + t.Errorf("expected 1 CREATE audit record, got %+v", auditRepo.records) + } +} + +func TestAPIPortalService_CreateAPIPortal_MissingName(t *testing.T) { + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}) + _, err := svc.CreateAPIPortal(testCreateReq{ + Handle: "acme", + SharedKey: testSharedKeyHex, + }.build(), "org-1", "user-1") + if err == nil { + t.Fatal("expected error for missing name") + } + if !apperror.ValidationFailed.Is(err) { + t.Errorf("want ValidationFailed, got %v", err) + } +} + +func TestAPIPortalService_CreateAPIPortal_InvalidHandle(t *testing.T) { + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}) + _, err := svc.CreateAPIPortal(testCreateReq{ + Handle: "AB", // too short + uppercase + Name: "x", + SharedKey: testSharedKeyHex, + }.build(), "org-1", "user-1") + if err == nil { + t.Fatal("expected error for invalid handle") + } +} + +func TestAPIPortalService_CreateAPIPortal_MissingSharedKey(t *testing.T) { + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: &model.Organization{}}, &mockAPIPortalAuditRepository{}) + _, err := svc.CreateAPIPortal(testCreateReq{ + Handle: "acme", + Name: "Acme", + URL: "https://acme.example.com", + SharedKey: "", + }.build(), "org-1", "user-1") + if err == nil || !apperror.ValidationFailed.Is(err) { + t.Fatalf("want ValidationFailed for empty sharedKey, got %v", err) + } +} + +func TestAPIPortalService_CreateAPIPortal_InvalidSharedKey(t *testing.T) { + cases := []struct { + name string + value string + }{ + {"too_short", "abcd"}, + {"too_long", strings.Repeat("a", 65)}, + {"non_hex", strings.Repeat("z", 64)}, + {"has_spaces", "aaaa aaaa aaaa aaaa aaaa aaaa aaaa aaaa aaaa aaaa aaaa aaaa aaaa aaa"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc := newTestAPIPortalService(t, + &mockAPIPortalRepository{}, + &mockAPIPortalOrgRepository{result: &model.Organization{}}, + &mockAPIPortalAuditRepository{}, + ) + _, err := svc.CreateAPIPortal(testCreateReq{ + Handle: "acme", + Name: "Acme", + URL: "https://acme.example.com", + SharedKey: tc.value, + }.build(), "org-1", "user-1") + if err == nil || !apperror.ValidationFailed.Is(err) { + t.Errorf("want ValidationFailed for sharedKey=%q, got %v", tc.value, err) + } + }) + } +} + +func TestAPIPortalService_CreateAPIPortal_OrgNotFound(t *testing.T) { + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: nil}, &mockAPIPortalAuditRepository{}) + _, err := svc.CreateAPIPortal(testCreateReq{ + Handle: "acme", Name: "Acme", SharedKey: testSharedKeyHex, + URL: "https://acme.example.com", + }.build(), "org-missing", "user-1") + if err == nil || !apperror.OrganizationNotFound.Is(err) { + t.Fatalf("want OrganizationNotFound, got %v", err) + } +} + +func TestAPIPortalService_CreateAPIPortal_HandleAlreadyExists(t *testing.T) { + svc := newTestAPIPortalService(t, + &mockAPIPortalRepository{existsResult: true}, + &mockAPIPortalOrgRepository{result: &model.Organization{}}, + &mockAPIPortalAuditRepository{}, + ) + _, err := svc.CreateAPIPortal(testCreateReq{ + Handle: "acme", Name: "Acme", SharedKey: testSharedKeyHex, + URL: "https://acme.example.com", + }.build(), "org-1", "user-1") + if err == nil || !apperror.APIPortalExists.Is(err) { + t.Fatalf("want APIPortalExists, got %v", err) + } +} + +func TestAPIPortalService_CreateAPIPortal_RaceOnUniqueConstraint(t *testing.T) { + // Exists() returns false (no row yet), then Create() races against another + // insert and hits the UNIQUE constraint. Service must translate to Conflict. + svc := newTestAPIPortalService(t, + &mockAPIPortalRepository{existsResult: false, createReturnUnique: true}, + &mockAPIPortalOrgRepository{result: &model.Organization{}}, + &mockAPIPortalAuditRepository{}, + ) + _, err := svc.CreateAPIPortal(testCreateReq{ + Handle: "acme", Name: "Acme", SharedKey: testSharedKeyHex, + URL: "https://acme.example.com", + }.build(), "org-1", "user-1") + if err == nil || !apperror.APIPortalExists.Is(err) { + t.Fatalf("want APIPortalExists on race, got %v", err) + } +} + +func TestAPIPortalService_CreateAPIPortal_InvalidURL(t *testing.T) { + cases := []struct { + name string + url string + }{ + {"http_rejected", "http://portal.example.com"}, + {"file_scheme", "file:///etc/passwd"}, + {"metadata_service_http", "http://169.254.169.254/latest/meta-data/"}, + {"javascript_scheme", "javascript:alert(1)"}, + {"relative_url", "portal.example.com"}, + {"scheme_only", "https://"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc := newTestAPIPortalService(t, + &mockAPIPortalRepository{}, + &mockAPIPortalOrgRepository{result: &model.Organization{}}, + &mockAPIPortalAuditRepository{}, + ) + _, err := svc.CreateAPIPortal(testCreateReq{ + Handle: "acme", + Name: "Acme", + SharedKey: testSharedKeyHex, + URL: tc.url, + }.build(), "org-1", "user-1") + if err == nil || !apperror.ValidationFailed.Is(err) { + t.Errorf("want ValidationFailed for %q, got %v", tc.url, err) + } + }) + } +} + +func TestAPIPortalService_CreateAPIPortal_ValidHTTPSAccepted(t *testing.T) { + svc := newTestAPIPortalService(t, + &mockAPIPortalRepository{}, + &mockAPIPortalOrgRepository{result: &model.Organization{}}, + &mockAPIPortalAuditRepository{}, + ) + got, err := svc.CreateAPIPortal(testCreateReq{ + Handle: "acme", + Name: "Acme", + SharedKey: testSharedKeyHex, + URL: "https://portal.example.com:9443/base", + }.build(), "org-1", "user-1") + if err != nil { + t.Fatalf("valid https URL rejected: %v", err) + } + if got.Url != "https://portal.example.com:9443/base" { + t.Errorf("URL not preserved: %q", got.Url) + } +} + +func TestAPIPortalService_CreateAPIPortal_EmptyURLRejected(t *testing.T) { + // OSS requires the operator to supply a reachable URL. Empty is rejected. + svc := newTestAPIPortalService(t, + &mockAPIPortalRepository{}, + &mockAPIPortalOrgRepository{result: &model.Organization{}}, + &mockAPIPortalAuditRepository{}, + ) + _, err := svc.CreateAPIPortal(testCreateReq{ + Handle: "acme", + Name: "Acme", + SharedKey: testSharedKeyHex, + URL: "", + }.build(), "org-1", "user-1") + if err == nil || !apperror.ValidationFailed.Is(err) { + t.Fatalf("want ValidationFailed for empty URL, got %v", err) + } +} + +func TestAPIPortalService_CreateAPIPortal_ResponseDoesNotEchoSharedKey(t *testing.T) { + // Response schema doesn't declare a sharedKey field; a create request + // that supplies one MUST NOT round-trip it in any form on the response. + // Belt-and-suspenders check on top of the OpenAPI writeOnly guarantee. + svc := newTestAPIPortalService(t, + &mockAPIPortalRepository{}, + &mockAPIPortalOrgRepository{result: &model.Organization{}}, + &mockAPIPortalAuditRepository{}, + ) + got, err := svc.CreateAPIPortal(testCreateReq{ + Handle: "acme", Name: "Acme", URL: "https://acme.example.com", + SharedKey: testSharedKeyHex, + }.build(), "org-1", "user-1") + if err != nil { + t.Fatalf("Create: %v", err) + } + // The generated ApiPortalResponse type doesn't have a SharedKey field + // at compile time (dropped from OpenAPI). If someone re-adds it in the + // future, this test will fail to compile — an intentional trip-wire. + // We also assert Handle / Url / metadata to catch a scenario where the + // entire response somehow gets replaced with a struct that DOES have a + // SharedKey field but leaks it via marshalling. + if derefStr(got.Handle) != "acme" || got.Url != "https://acme.example.com" { + t.Errorf("response shape wrong: %+v", got) + } +} + +// --- Get tests --- + +func TestAPIPortalService_GetAPIPortal_HappyPath(t *testing.T) { + portal := &model.APIPortal{ID: "p1", Handle: "acme", OrganizationID: "org-1"} + svc := newTestAPIPortalService(t, + &mockAPIPortalRepository{getResult: portal}, + &mockAPIPortalOrgRepository{result: &model.Organization{}}, + &mockAPIPortalAuditRepository{}, + ) + got, err := svc.GetAPIPortal("acme", "org-1") + if err != nil { + t.Fatalf("GetAPIPortal: %v", err) + } + if got == nil || derefStr(got.Handle) != portal.Handle { + t.Errorf("returned portal wrong shape: %+v", got) + } +} + +func TestAPIPortalService_GetAPIPortal_NotFound(t *testing.T) { + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{getResult: nil}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) + _, err := svc.GetAPIPortal("ghost", "org-1") + if err == nil || !apperror.APIPortalNotFound.Is(err) { + t.Fatalf("want APIPortalNotFound, got %v", err) + } +} + +// --- List tests --- + +func TestAPIPortalService_ListAPIPortals_HappyPath(t *testing.T) { + portals := []*model.APIPortal{{ID: "p1", Handle: "a"}, {ID: "p2", Handle: "b"}} + svc := newTestAPIPortalService(t, + &mockAPIPortalRepository{listResult: portals, countResult: 5}, + &mockAPIPortalOrgRepository{result: &model.Organization{}}, + &mockAPIPortalAuditRepository{}, + ) + resp, err := svc.ListAPIPortals("org-1", 0, 0, "", "", "") + if err != nil { + t.Fatalf("ListAPIPortals: %v", err) + } + if resp.Count != 2 || resp.Pagination.Total != 5 { + t.Errorf("counts wrong: %+v", resp) + } + if resp.Pagination.Limit != 20 { // default + t.Errorf("default limit not applied: %d", resp.Pagination.Limit) + } +} + +func TestAPIPortalService_ListAPIPortals_OrgNotFound(t *testing.T) { + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{}, &mockAPIPortalOrgRepository{result: nil}, &mockAPIPortalAuditRepository{}) + _, err := svc.ListAPIPortals("org-missing", 0, 0, "", "", "") + if err == nil || !apperror.OrganizationNotFound.Is(err) { + t.Fatalf("want OrganizationNotFound, got %v", err) + } +} + +func TestAPIPortalService_ListAPIPortals_LimitClamping(t *testing.T) { + svc := newTestAPIPortalService(t, + &mockAPIPortalRepository{listResult: nil, countResult: 0}, + &mockAPIPortalOrgRepository{result: &model.Organization{}}, + &mockAPIPortalAuditRepository{}, + ) + resp, err := svc.ListAPIPortals("org-1", 500, -5, "", "", "") + if err != nil { + t.Fatalf("ListAPIPortals: %v", err) + } + if resp.Pagination.Limit != 100 { + t.Errorf("limit not clamped to 100: %d", resp.Pagination.Limit) + } + if resp.Pagination.Offset != 0 { + t.Errorf("negative offset not normalized to 0: %d", resp.Pagination.Offset) + } +} + +// --- Update tests --- + +func TestAPIPortalService_UpdateAPIPortal_HappyPath(t *testing.T) { + existing := &model.APIPortal{ + ID: "p1", Handle: "acme", OrganizationID: "org-1", + Name: "old", + URL: "https://acme.example.com", + Status: constants.APIPortalStatusActive, + InternalAuthKey: []byte("pre-existing-ciphertext"), + } + portalRepo := &mockAPIPortalRepository{getResult: existing} + auditRepo := &mockAPIPortalAuditRepository{} + svc := newTestAPIPortalService(t, portalRepo, &mockAPIPortalOrgRepository{}, auditRepo) + + req := testUpdateReq{ + Name: apiPortalStrPtr("Renamed"), + Description: apiPortalStrPtr("new description"), + } + got, err := svc.UpdateAPIPortal("acme", req.build(), "org-1", "editor") + if err != nil { + t.Fatalf("UpdateAPIPortal: %v", err) + } + if got.Name != "Renamed" || derefStr(got.Description) != "new description" { + t.Errorf("mutable fields not applied: %+v", got) + } + if derefStr(got.Handle) != "acme" || derefStr(got.Id) != "acme" { + t.Errorf("immutable fields changed: %+v", got) + } + if portalRepo.updateCapturedInput == nil { + t.Fatal("repository Update not called") + } + if portalRepo.updateCapturedInput.UpdatedBy != "editor" { + t.Errorf("updatedBy not populated: %q", portalRepo.updateCapturedInput.UpdatedBy) + } + // InternalAuthKey untouched — no sharedKey in the request. + if !bytes.Equal(portalRepo.updateCapturedInput.InternalAuthKey, []byte("pre-existing-ciphertext")) { + t.Errorf("InternalAuthKey mutated on non-rotate Update: %q", + portalRepo.updateCapturedInput.InternalAuthKey) + } + if len(auditRepo.records) != 1 || auditRepo.records[0].action != "UPDATE" { + t.Errorf("expected 1 UPDATE audit record, got %+v", auditRepo.records) + } +} + +func TestAPIPortalService_UpdateAPIPortal_SharedKeyRotation(t *testing.T) { + // PUT with sharedKey rotates the stored ciphertext. Same code path OSS + // operators + cloud plugin use post-devportal-side rotation. + existing := &model.APIPortal{ + ID: "p1", Handle: "acme", OrganizationID: "org-1", + Name: "Acme", + URL: "https://acme.example.com", + Status: constants.APIPortalStatusActive, + InternalAuthKey: []byte("old-ciphertext"), + } + portalRepo := &mockAPIPortalRepository{getResult: existing} + svc := newTestAPIPortalService(t, portalRepo, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) + + newKey := strings.Repeat("b", 64) + req := testUpdateReq{SharedKey: &newKey} + if _, err := svc.UpdateAPIPortal("acme", req.build(), "org-1", "editor"); err != nil { + t.Fatalf("rotation: %v", err) + } + if bytes.Equal(portalRepo.updateCapturedInput.InternalAuthKey, []byte("old-ciphertext")) { + t.Error("InternalAuthKey not rotated; still holds pre-rotation ciphertext") + } + if len(portalRepo.updateCapturedInput.InternalAuthKey) == 0 { + t.Error("InternalAuthKey empty after rotation; expected fresh ciphertext") + } +} + +func TestAPIPortalService_UpdateAPIPortal_InvalidSharedKeyRejected(t *testing.T) { + existing := &model.APIPortal{ + ID: "p1", Handle: "acme", OrganizationID: "org-1", + Name: "Acme", Status: constants.APIPortalStatusActive, + } + svc := newTestAPIPortalService(t, + &mockAPIPortalRepository{getResult: existing}, + &mockAPIPortalOrgRepository{}, + &mockAPIPortalAuditRepository{}, + ) + bad := "not-hex" + _, err := svc.UpdateAPIPortal("acme", testUpdateReq{SharedKey: &bad}.build(), "org-1", "editor") + if err == nil || !apperror.ValidationFailed.Is(err) { + t.Fatalf("want ValidationFailed for bad sharedKey on Update, got %v", err) + } +} + +func TestAPIPortalService_UpdateAPIPortal_PartialUpdate(t *testing.T) { + existing := &model.APIPortal{ + ID: "p1", Handle: "acme", OrganizationID: "org-1", + Name: "keep", + URL: "https://keep.example.com", + Status: constants.APIPortalStatusActive, + InternalAuthKey: []byte("keep-ciphertext"), + } + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{getResult: existing}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) + // Only Description supplied; everything else must remain unchanged. + got, err := svc.UpdateAPIPortal("acme", testUpdateReq{Description: apiPortalStrPtr("new desc")}.build(), "org-1", "editor") + if err != nil { + t.Fatalf("UpdateAPIPortal: %v", err) + } + if derefStr(got.Description) != "new desc" { + t.Errorf("Description not updated: %q", derefStr(got.Description)) + } + if got.Name != "keep" || got.Url != "https://keep.example.com" { + t.Errorf("unset fields were mutated: %+v", got) + } +} + +func TestAPIPortalService_UpdateAPIPortal_InvalidURLRejected(t *testing.T) { + existing := &model.APIPortal{ + ID: "p1", Handle: "acme", OrganizationID: "org-1", + Name: "Acme", Status: constants.APIPortalStatusActive, + } + svc := newTestAPIPortalService(t, + &mockAPIPortalRepository{getResult: existing}, + &mockAPIPortalOrgRepository{}, + &mockAPIPortalAuditRepository{}, + ) + _, err := svc.UpdateAPIPortal("acme", testUpdateReq{ + URL: apiPortalStrPtr("http://insecure.example.com"), + }.build(), "org-1", "editor") + if err == nil || !apperror.ValidationFailed.Is(err) { + t.Fatalf("want ValidationFailed for http URL on Update, got %v", err) + } +} + +func TestAPIPortalService_UpdateAPIPortal_NotFound(t *testing.T) { + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{getResult: nil}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) + _, err := svc.UpdateAPIPortal("ghost", testUpdateReq{Name: apiPortalStrPtr("x")}.build(), "org-1", "editor") + if err == nil || !apperror.APIPortalNotFound.Is(err) { + t.Fatalf("want APIPortalNotFound, got %v", err) + } +} + +func TestAPIPortalService_UpdateAPIPortal_EmptyName(t *testing.T) { + existing := &model.APIPortal{ID: "p1", Handle: "acme", OrganizationID: "org-1", Name: "old"} + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{getResult: existing}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) + _, err := svc.UpdateAPIPortal("acme", testUpdateReq{Name: apiPortalStrPtr(" ")}.build(), "org-1", "editor") + if err == nil || !apperror.ValidationFailed.Is(err) { + t.Fatalf("want ValidationFailed for empty name, got %v", err) + } +} + +// --- Delete tests --- + +func TestAPIPortalService_DeleteAPIPortal_HappyPath(t *testing.T) { + existing := &model.APIPortal{ID: "p1", Handle: "acme", OrganizationID: "org-1"} + portalRepo := &mockAPIPortalRepository{getResult: existing} + auditRepo := &mockAPIPortalAuditRepository{} + svc := newTestAPIPortalService(t, portalRepo, &mockAPIPortalOrgRepository{}, auditRepo) + if err := svc.DeleteAPIPortal("acme", "org-1", "actor"); err != nil { + t.Fatalf("DeleteAPIPortal: %v", err) + } + if portalRepo.deleteCalledWith != [2]string{"p1", "org-1"} { + t.Errorf("Delete called with wrong args: %+v", portalRepo.deleteCalledWith) + } + if len(auditRepo.records) != 1 || auditRepo.records[0].action != "DELETE" { + t.Errorf("expected 1 DELETE audit record, got %+v", auditRepo.records) + } +} + +func TestAPIPortalService_DeleteAPIPortal_NotFound(t *testing.T) { + svc := newTestAPIPortalService(t, &mockAPIPortalRepository{getResult: nil}, &mockAPIPortalOrgRepository{}, &mockAPIPortalAuditRepository{}) + err := svc.DeleteAPIPortal("ghost", "org-1", "actor") + if err == nil || !apperror.APIPortalNotFound.Is(err) { + t.Fatalf("want APIPortalNotFound, got %v", err) + } +} From 3643d509ba32b721c0ba169268442392e535c8dc Mon Sep 17 00:00:00 2001 From: dushaniw Date: Fri, 11 Sep 2026 19:11:10 +0530 Subject: [PATCH 24/25] api-portals: address review comments (openapi description + registry race) - CreateApiPortal description no longer promises URL-less provisioning the schema does not support (url is required, empty string rejected). - APIPortalAuthRegistry.Get: fix cache-fill race with Invalidate. A Get in flight when Invalidate runs no longer repopulates the cache with the pre-invalidate provider; a per-key generation counter guards the fill and the just-built provider is returned to this caller without caching so the next Get rebuilds from the fresh row. --- .../internal/service/api_portal_auth.go | 44 ++++++++---- .../internal/service/api_portal_test.go | 68 +++++++++++++++++++ platform-api/resources/openapi.yaml | 8 +-- 3 files changed, 100 insertions(+), 20 deletions(-) diff --git a/platform-api/internal/service/api_portal_auth.go b/platform-api/internal/service/api_portal_auth.go index fbc171a691..fddcb21852 100644 --- a/platform-api/internal/service/api_portal_auth.go +++ b/platform-api/internal/service/api_portal_auth.go @@ -100,10 +100,11 @@ func (p *sharedKeyAuthProvider) InvalidateCache() { // next outbound call picks up whatever the row now says. Provider construction // (Decrypt on the row's internal_auth_key) happens on Get miss. type APIPortalAuthRegistry struct { - mu sync.Mutex - providers map[string]AuthProvider // key = registryKey(orgID, handle) - portalRepo repository.APIPortalRepository - vault vault.SecretVault + mu sync.Mutex + providers map[string]AuthProvider // key = registryKey(orgID, handle) + generations map[string]uint64 // bumped by Invalidate; guards cache-fill races + portalRepo repository.APIPortalRepository + vault vault.SecretVault } // NewAPIPortalAuthRegistry constructs the registry. portalRepo is used to load @@ -111,9 +112,10 @@ type APIPortalAuthRegistry struct { // decrypt that value. func NewAPIPortalAuthRegistry(portalRepo repository.APIPortalRepository, v vault.SecretVault) *APIPortalAuthRegistry { return &APIPortalAuthRegistry{ - providers: map[string]AuthProvider{}, - portalRepo: portalRepo, - vault: v, + providers: map[string]AuthProvider{}, + generations: map[string]uint64{}, + portalRepo: portalRepo, + vault: v, } } @@ -129,13 +131,19 @@ func registryKey(orgID, portalHandle string) string { // Invalidate drops the cached provider for a portal handle in an org. No-op // when there is no cached entry (idempotent, safe to call from Delete paths). // Called by the service on every Update / Delete of a portal row. +// +// Bumps the per-key generation so a concurrent Get that has already read the +// pre-Invalidate row does not repopulate the cache with the stale provider +// after Invalidate returns. func (r *APIPortalAuthRegistry) Invalidate(portalHandle, orgID string) { if r == nil { return } r.mu.Lock() defer r.mu.Unlock() - delete(r.providers, registryKey(orgID, portalHandle)) + key := registryKey(orgID, portalHandle) + delete(r.providers, key) + r.generations[key]++ } // Get returns the AuthProvider for the (org, portal) pair, constructing + @@ -145,9 +153,12 @@ func (r *APIPortalAuthRegistry) Invalidate(portalHandle, orgID string) { // treats as a permanent configuration problem. // // Concurrent Gets for the same key race safely: the first one wins the map -// slot, subsequent ones return that stored provider (double-check under lock -// avoids constructing more than once). A rare double-decrypt on a lost race -// is preferable to holding the map lock across an I/O call to portalRepo. +// slot, subsequent ones return that stored provider. If Invalidate runs +// between the row read and the cache fill, the per-key generation counter +// no longer matches the snapshot and the cache write is skipped, so an +// invalidated key never re-appears in the cache from an in-flight Get. This +// call still returns the just-built provider (built from the row state we +// read); the next Get rebuilds from the updated row. func (r *APIPortalAuthRegistry) Get(portalHandle, orgID string) (AuthProvider, error) { if r == nil { return nil, fmt.Errorf("shared-key AuthProvider registry is not initialised") @@ -159,6 +170,7 @@ func (r *APIPortalAuthRegistry) Get(portalHandle, orgID string) (AuthProvider, e r.mu.Unlock() return p, nil } + genSnapshot := r.generations[key] r.mu.Unlock() portal, err := r.portalRepo.GetByHandleAndOrgID(portalHandle, orgID) @@ -175,13 +187,15 @@ func (r *APIPortalAuthRegistry) Get(portalHandle, orgID string) (AuthProvider, e } r.mu.Lock() - // Another goroutine may have installed a provider while we were - // decrypting; prefer the existing one to keep a single instance per key. + defer r.mu.Unlock() + if r.generations[key] != genSnapshot { + // Invalidate ran while we were decrypting. Do not cache; caller uses + // the provider it has (built from the pre-Invalidate row). + return provider, nil + } if existing, ok := r.providers[key]; ok { - r.mu.Unlock() return existing, nil } r.providers[key] = provider - r.mu.Unlock() return provider, nil } diff --git a/platform-api/internal/service/api_portal_test.go b/platform-api/internal/service/api_portal_test.go index 00fcca12c0..723b1d66b0 100644 --- a/platform-api/internal/service/api_portal_test.go +++ b/platform-api/internal/service/api_portal_test.go @@ -19,9 +19,11 @@ package service import ( "bytes" + "context" "errors" "strings" "testing" + "time" "github.com/wso2/api-platform/platform-api/api" "github.com/wso2/api-platform/platform-api/internal/apperror" @@ -710,3 +712,69 @@ func TestAPIPortalService_DeleteAPIPortal_NotFound(t *testing.T) { t.Fatalf("want APIPortalNotFound, got %v", err) } } + +// --- Registry cache-fill race --- + +// blockingPortalRepo lets a test park a GetByHandleAndOrgID call at a known +// point so the test can interleave an Invalidate against the in-flight Get. +type blockingPortalRepo struct { + mockAPIPortalRepository + enter chan struct{} // closed by the repo when Get is entered + release chan struct{} // read by the repo to hold until the test says go + portal *model.APIPortal +} + +func (r *blockingPortalRepo) GetByHandleAndOrgID(handle, orgUUID string) (*model.APIPortal, error) { + close(r.enter) + <-r.release + return r.portal, nil +} + +// A Get in flight when Invalidate runs must not repopulate the cache with the +// stale provider. Locks the fix for the TOCTOU between the row read and the +// cache fill. +func TestAPIPortalAuthRegistry_GetDoesNotCacheAfterConcurrentInvalidate(t *testing.T) { + v := newTestVault(t) + // Row's InternalAuthKey must be a valid ciphertext so NewSharedKeyAuthProvider + // succeeds. Encrypt a placeholder raw here. + ct, err := v.Encrypt(context.Background(), testSharedKeyHex) + if err != nil { + t.Fatalf("seed encrypt: %v", err) + } + repo := &blockingPortalRepo{ + enter: make(chan struct{}), + release: make(chan struct{}), + portal: &model.APIPortal{Handle: "acme", OrganizationID: "org-1", InternalAuthKey: ct}, + } + reg := NewAPIPortalAuthRegistry(repo, v) + + // Start the Get; it will park inside the repo call. + got := make(chan AuthProvider, 1) + go func() { + p, err := reg.Get("acme", "org-1") + if err != nil { + t.Errorf("Get: %v", err) + } + got <- p + }() + <-repo.enter + + // Invalidate while Get is parked. This is the race the fix guards. + reg.Invalidate("acme", "org-1") + + // Let Get complete. It builds a provider from the row we captured and + // must NOT cache it (generation changed). + close(repo.release) + select { + case <-got: + case <-time.After(time.Second): + t.Fatal("Get did not return after release") + } + + reg.mu.Lock() + _, cached := reg.providers[registryKey("org-1", "acme")] + reg.mu.Unlock() + if cached { + t.Error("Get repopulated cache after concurrent Invalidate; stale provider would persist") + } +} diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index 2344f93c5a..e79eae21d1 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -4459,11 +4459,9 @@ paths: post: summary: Create an API Portal description: | - Registers a new API Portal in the caller's organization. If a URL is - provided, the portal is registered against that existing endpoint; if - omitted, a new portal is provisioned and the URL is populated when the - instance becomes reachable. Organization ID is extracted from the JWT - token. + Registers a new API Portal in the caller's organization against an + existing portal URL. The URL is required. Organization ID is extracted + from the JWT token. operationId: CreateApiPortal security: - OAuth2Security: From 5d08afb6e24a49fa4ba1dfee3d6f3739024dd535 Mon Sep 17 00:00:00 2001 From: dushaniw Date: Fri, 11 Sep 2026 19:52:10 +0530 Subject: [PATCH 25/25] api-portals: prune long code comments across the resource Comments reduced to short WHY-only lines against the current-base code. Long narrative history, plan-phase markers, PR/commit refs, and resource-footprint anecdotes removed. Net -164 lines across 9 files. No code, signature, control-flow, or test-logic changes. --- platform-api/internal/constants/constants.go | 19 ++--- platform-api/internal/handler/api_portal.go | 18 ++-- platform-api/internal/model/api_portal.go | 12 +-- .../internal/repository/api_portal.go | 19 ++--- .../internal/repository/interfaces.go | 4 - platform-api/internal/service/api_portal.go | 75 ++++------------- .../internal/service/api_portal_auth.go | 83 ++++--------------- .../internal/service/api_portal_translate.go | 19 ++--- platform-api/pdk/deps.go | 25 +----- 9 files changed, 55 insertions(+), 219 deletions(-) diff --git a/platform-api/internal/constants/constants.go b/platform-api/internal/constants/constants.go index d35c6606fe..534cf771ba 100644 --- a/platform-api/internal/constants/constants.go +++ b/platform-api/internal/constants/constants.go @@ -207,29 +207,20 @@ var ValidGatewayTokenStatuses = map[string]bool{ GatewayTokenStatusRevoked: true, } -// API Portal status constants. The column exists on api_portals for -// future extensibility but is not surfaced on the wire in the OSS offering: -// OSS registers a portal that's already running, so every OSS row is created -// as APIPortalStatusActive and never mutated by clients. +// API Portal status constants. const ( APIPortalStatusPending = "pending" APIPortalStatusActive = "active" APIPortalStatusFailed = "failed" ) -// API Portal outbound-auth constants. Platform-API authenticates to an API -// Portal's admin REST endpoints with a shared key (RFC 7235 custom auth -// scheme), NOT an OAuth 2.0 bearer token. See internal/service/api_portal_auth.go -// and the design doc for the full mechanism. +// API Portal outbound-auth constants. The scheme is a custom RFC 7235 name, +// not OAuth 2.0 Bearer; the portal middleware sha256s the raw for verification. const ( - // APIPortalSharedKeyAuthScheme is the Authorization-header scheme name - // Platform-API sends on outbound publishing calls. The portal side matches - // case-insensitively; we use the CamelCase spelling on the wire. + // APIPortalSharedKeyAuthScheme is the Authorization-header scheme name; matched case-insensitively by the portal. APIPortalSharedKeyAuthScheme = "SharedKey" - // APIPortalSharedKeyHexLength is the required length of the raw shared key - // (in hex characters). 64 hex chars = 32 bytes = 256 bits of entropy, matching - // what `openssl rand -hex 32` produces on the portal-side setup script. + // APIPortalSharedKeyHexLength is the required raw-key length in hex chars (32 bytes of entropy). APIPortalSharedKeyHexLength = 64 ) diff --git a/platform-api/internal/handler/api_portal.go b/platform-api/internal/handler/api_portal.go index 7b76aa97a0..e34e90ae60 100644 --- a/platform-api/internal/handler/api_portal.go +++ b/platform-api/internal/handler/api_portal.go @@ -34,10 +34,7 @@ import ( "github.com/wso2/api-platform/httpkit/httputil" ) -// APIPortalHandler exposes /api-portals CRUD. The generated OpenAPI types -// (api.CreateApiPortalRequest / api.ApiPortalResponse / …) are the wire contract -// AND the service-layer contract — the service speaks in these directly so its -// methods also satisfy pdk.APIPortals for plugins. +// APIPortalHandler exposes /api-portals CRUD. type APIPortalHandler struct { svc *service.APIPortalService identity *service.IdentityService @@ -49,7 +46,7 @@ func NewAPIPortalHandler(svc *service.APIPortalService, identity *service.Identi return &APIPortalHandler{svc: svc, identity: identity, slogger: slogger} } -// CreateAPIPortal — POST /api-portals +// CreateAPIPortal handles POST /api-portals. func (h *APIPortalHandler) CreateAPIPortal(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { @@ -76,7 +73,7 @@ func (h *APIPortalHandler) CreateAPIPortal(w http.ResponseWriter, r *http.Reques return nil } -// GetAPIPortal — GET /api-portals/{apiPortalId} +// GetAPIPortal handles GET /api-portals/{apiPortalId}. func (h *APIPortalHandler) GetAPIPortal(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { @@ -96,7 +93,7 @@ func (h *APIPortalHandler) GetAPIPortal(w http.ResponseWriter, r *http.Request) return nil } -// ListAPIPortals — GET /api-portals +// ListAPIPortals handles GET /api-portals. func (h *APIPortalHandler) ListAPIPortals(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { @@ -113,7 +110,7 @@ func (h *APIPortalHandler) ListAPIPortals(w http.ResponseWriter, r *http.Request return nil } -// UpdateAPIPortal — PUT /api-portals/{apiPortalId} +// UpdateAPIPortal handles PUT /api-portals/{apiPortalId}. func (h *APIPortalHandler) UpdateAPIPortal(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { @@ -143,7 +140,7 @@ func (h *APIPortalHandler) UpdateAPIPortal(w http.ResponseWriter, r *http.Reques return nil } -// DeleteAPIPortal — DELETE /api-portals/{apiPortalId} +// DeleteAPIPortal handles DELETE /api-portals/{apiPortalId}. func (h *APIPortalHandler) DeleteAPIPortal(w http.ResponseWriter, r *http.Request) error { orgID, ok := middleware.GetOrganizationFromRequest(r) if !ok { @@ -177,8 +174,7 @@ func (h *APIPortalHandler) RegisterRoutes(mux router.Router) { mux.HandleFunc("DELETE "+base+"/{apiPortalId}", middleware.MapErrors(h.slogger, h.DeleteAPIPortal)) } -// derefStr returns the pointed-to string or "" when nil. Local helper used by -// setLocation to source the Location header from the api-generated response. +// derefStr returns the pointed-to string or "" when nil. func derefStr(p *string) string { if p == nil { return "" diff --git a/platform-api/internal/model/api_portal.go b/platform-api/internal/model/api_portal.go index d886c4f463..a171bd78ed 100644 --- a/platform-api/internal/model/api_portal.go +++ b/platform-api/internal/model/api_portal.go @@ -24,16 +24,8 @@ import ( ) // APIPortal represents an API Portal registered within an organization. -// -// Two persisted blobs, split by consumer: -// - InternalAuthKey is the encrypted raw shared key Platform-API sends as -// `Authorization: SharedKey ` on outbound publishing calls. Stored -// as AES-GCM ciphertext (nonce || ciphertext) via internal/vault; the -// plaintext key is only ever handed to the caller ONCE at Create/Update -// time and never returned on any read path. -// - Metadata is opaque pass-through data (never encrypted, always returned). -// Typically carries the cloud-side OIDC endpoints that the portal pod uses -// for consumer login (stsIssuer, stsJwksUrl, etc.); usually empty in OSS. +// InternalAuthKey holds the AES-GCM ciphertext of the shared key (never returned on reads); +// Metadata is opaque pass-through JSON. type APIPortal struct { ID string `json:"id" db:"uuid"` OrganizationID string `json:"organizationId" db:"organization_uuid"` diff --git a/platform-api/internal/repository/api_portal.go b/platform-api/internal/repository/api_portal.go index 0cab38d228..816ca106f0 100644 --- a/platform-api/internal/repository/api_portal.go +++ b/platform-api/internal/repository/api_portal.go @@ -72,11 +72,7 @@ func scanAPIPortalRow(scanner interface { return portal, nil } -// marshalAPIPortalBlob serializes a JSON blob column value. A nil or empty map -// becomes a nil byte slice so the driver stores SQL NULL — the column is -// nullable and there is no reason to distinguish "operator supplied nothing" -// from "operator supplied {}". readers (unmarshalAPIPortalBlob) mirror this -// by returning a nil map for a NULL or empty-bytes read. +// marshalAPIPortalBlob serializes a JSON blob column value; nil/empty map becomes nil bytes so the driver stores SQL NULL. func marshalAPIPortalBlob(m map[string]interface{}, field string) ([]byte, error) { if len(m) == 0 { return nil, nil @@ -88,10 +84,7 @@ func marshalAPIPortalBlob(m map[string]interface{}, field string) ([]byte, error return b, nil } -// unmarshalAPIPortalBlob deserializes a JSON blob. Returns a nil map for a -// NULL column value or empty bytes so the response can rely on the model's -// `json:",omitempty"` tag to elide the field entirely for portals that carry -// no metadata (typical OSS case). +// unmarshalAPIPortalBlob deserializes a JSON blob; NULL/empty becomes nil so `omitempty` elides the field on wire. func unmarshalAPIPortalBlob(b []byte, field string) (map[string]interface{}, error) { if len(b) == 0 { return nil, nil @@ -197,7 +190,7 @@ func (r *APIPortalRepo) ListPaginated(orgUUID string, opts ListOptions) ([]*mode return portals, rows.Err() } -// Count returns the total number of API Portals matching the org (+ optional search), independent of pagination. +// Count returns the total matching the org (and optional search), independent of pagination. func (r *APIPortalRepo) Count(orgUUID string, search string) (int, error) { var args []interface{} conditions := []string{`organization_uuid = ?`} @@ -214,9 +207,7 @@ func (r *APIPortalRepo) Count(orgUUID string, search string) (int, error) { return total, nil } -// Update mutates only the whitelisted fields; immutable columns (uuid, organization_uuid, -// handle, created_by, created_at) are never touched. The caller is -// responsible for populating UpdatedBy before invoking. +// Update mutates only whitelisted fields; uuid, organization_uuid, handle, created_by, created_at are immutable. Caller must set UpdatedBy. func (r *APIPortalRepo) Update(portal *model.APIPortal) error { portal.UpdatedAt = time.Now().UTC() metadataBytes, err := marshalAPIPortalBlob(portal.Metadata, "metadata") @@ -249,7 +240,7 @@ func (r *APIPortalRepo) Update(portal *model.APIPortal) error { return nil } -// Delete removes an API Portal row with organization isolation. +// Delete removes an API Portal row, scoped to orgUUID. func (r *APIPortalRepo) Delete(portalID, orgUUID string) error { query := `DELETE FROM api_portals WHERE uuid = ? AND organization_uuid = ?` result, err := r.db.Exec(r.db.Rebind(query), portalID, orgUUID) diff --git a/platform-api/internal/repository/interfaces.go b/platform-api/internal/repository/interfaces.go index 990dcf31d5..23ffc33e70 100644 --- a/platform-api/internal/repository/interfaces.go +++ b/platform-api/internal/repository/interfaces.go @@ -295,15 +295,11 @@ type LLMProxyRepository interface { } // APIPortalRepository defines the interface for API Portal persistence. -// See internal/model/api_portal.go for field semantics and internal/database/schema.postgres.sql -// (api_portals table) for storage layout. type APIPortalRepository interface { Create(portal *model.APIPortal) error GetByUUID(portalID, orgUUID string) (*model.APIPortal, error) GetByHandleAndOrgID(handle, orgUUID string) (*model.APIPortal, error) - // ListPaginated returns a page of API Portals scoped to the organization. ListPaginated(orgUUID string, opts ListOptions) ([]*model.APIPortal, error) - // Count returns the total number of matching API Portals independent of pagination. Count(orgUUID string, search string) (int, error) Update(portal *model.APIPortal) error Delete(portalID, orgUUID string) error diff --git a/platform-api/internal/service/api_portal.go b/platform-api/internal/service/api_portal.go index ca9ec82c8e..e6040e0594 100644 --- a/platform-api/internal/service/api_portal.go +++ b/platform-api/internal/service/api_portal.go @@ -36,19 +36,7 @@ import ( "github.com/wso2/api-platform/platform-api/internal/vault" ) -// validateAPIPortalURL enforces input-time constraints on a caller-supplied -// portal URL: -// - Empty is rejected on Create (the portal must be reachable to register it), -// see CreateAPIPortal below. -// - Non-empty must parse as an absolute URL with a host, and use the https -// scheme. This blocks stored SSRF via `file://`, `javascript:`, and any -// plain-http URL that could be pointed at instance-metadata endpoints such -// as http://169.254.169.254/. -// -// Deeper outbound-hardening (private-IP blocklist, DNS-rebinding checks, -// redirect controls) is intentionally NOT enforced here, it belongs in the -// shared outbound HTTP client the publisher will build later, so every -// outbound integration gets the same protection uniformly. +// validateAPIPortalURL requires https + absolute URL with host to block stored SSRF vectors (file://, javascript:, plain-http metadata endpoints). func validateAPIPortalURL(raw string) (string, error) { trimmed := strings.TrimSpace(raw) if trimmed == "" { @@ -67,16 +55,10 @@ func validateAPIPortalURL(raw string) (string, error) { return u.String(), nil } -// sharedKeyPattern matches a 64-character hex string, the exact shape the -// devportal-side setup script produces via `openssl rand -hex 32` and the -// portal middleware sha256's for verification. Any other shape is rejected -// here so we never encrypt-and-store a value the portal cannot possibly match. +// sharedKeyPattern matches the 64-hex-char shape produced by `openssl rand -hex 32`, which the portal middleware sha256s for verification. var sharedKeyPattern = regexp.MustCompile(`^[0-9a-fA-F]{64}$`) -// validateAndEncryptSharedKey checks the raw sharedKey format and returns the -// AES-GCM ciphertext the row column will hold. The plaintext is discarded once -// this function returns, the only path back to it is Decrypt, which the -// outbound-auth provider does per publish call. +// validateAndEncryptSharedKey returns AES-GCM ciphertext of the raw key; plaintext is discarded on return. func validateAndEncryptSharedKey(v vault.SecretVault, raw string) ([]byte, error) { trimmed := strings.TrimSpace(raw) if trimmed == "" { @@ -94,9 +76,6 @@ func validateAndEncryptSharedKey(v vault.SecretVault, raw string) ([]byte, error } // APIPortalService encapsulates business logic for the /api-portals resource. -// The handler layer translates OpenAPI-generated request/response DTOs into -// the service's own request structs so the service stays independent of the -// generated code. type APIPortalService struct { portalRepo repository.APIPortalRepository orgRepo repository.OrganizationRepository @@ -128,9 +107,7 @@ func NewAPIPortalService( } } -// invalidateCachedAuthProvider is a no-op when the service was constructed -// without a registry (e.g. in unit tests that don't need outbound auth). Keeps -// call sites clean of nil checks. +// invalidateCachedAuthProvider drops the cached provider; no-op when registry is nil (tests without outbound auth). func (s *APIPortalService) invalidateCachedAuthProvider(portalHandle, orgID string) { if s.authRegistry == nil { return @@ -138,14 +115,7 @@ func (s *APIPortalService) invalidateCachedAuthProvider(portalHandle, orgID stri s.authRegistry.Invalidate(portalHandle, orgID) } -// AuthHeaderForPortal returns the fully-formed Authorization header value the -// outbound publisher should attach to its next call to the portal's admin -// REST API, e.g. "SharedKey ". Wraps the registry lookup + provider -// caching so publisher code is a one-liner: `hdr, err := svc.AuthHeaderForPortal(ctx, handle, orgID)`. -// -// Returns APIPortalNotFound when the (handle, orgID) pair is unknown, and a -// plain error on decryption / configuration failures (the caller treats -// those as fatal for the publish call rather than retrying). +// AuthHeaderForPortal returns the "SharedKey " Authorization header for outbound calls to the portal's admin API. func (s *APIPortalService) AuthHeaderForPortal(ctx context.Context, portalHandle, orgID string) (string, error) { if s.authRegistry == nil { return "", fmt.Errorf("shared-key AuthProvider registry is not initialised") @@ -157,15 +127,13 @@ func (s *APIPortalService) AuthHeaderForPortal(ctx context.Context, portalHandle return provider.AuthorizationHeader(ctx) } -// PaginationInfo is the {total, offset, limit} triplet used to build the -// list-response envelope in api_portal_translate.go. +// PaginationInfo is the {total, offset, limit} triplet used to build the list-response envelope. type PaginationInfo struct { Total int Offset int Limit int } -// deref helpers used by the api-DTO-facing service methods. func derefStr(p *string) string { if p == nil { return "" @@ -173,10 +141,7 @@ func derefStr(p *string) string { return *p } -// CreateAPIPortal validates the request, enforces uniqueness of the handle, -// encrypts the caller-supplied shared key, and inserts a new row scoped to -// orgID. Speaks in api-generated types directly so it satisfies the -// pdk.APIPortals contract by shape. +// CreateAPIPortal validates the request, enforces handle uniqueness, encrypts the shared key, and inserts a row scoped to orgID. func (s *APIPortalService) CreateAPIPortal(req *api.CreateApiPortalRequest, orgID, createdBy string) (*api.ApiPortalResponse, error) { if req == nil { return nil, apperror.ValidationFailed.New("The request body is required.") @@ -233,7 +198,7 @@ func (s *APIPortalService) CreateAPIPortal(req *api.CreateApiPortalRequest, orgI if err := s.portalRepo.Create(portal); err != nil { if repository.IsUniqueViolation(err) { - // A concurrent create won the race between Exists and INSERT. + // Concurrent create won the race between Exists and INSERT. return nil, apperror.APIPortalExists.New() } return nil, err @@ -242,7 +207,7 @@ func (s *APIPortalService) CreateAPIPortal(req *api.CreateApiPortalRequest, orgI return ModelToAPIPortalResponse(portal), nil } -// GetAPIPortal returns a single API Portal identified by its handle (wire ID) within orgID. +// GetAPIPortal returns a single API Portal identified by its handle within orgID. func (s *APIPortalService) GetAPIPortal(handle, orgID string) (*api.ApiPortalResponse, error) { portal, err := s.portalRepo.GetByHandleAndOrgID(strings.TrimSpace(handle), orgID) if err != nil { @@ -254,10 +219,7 @@ func (s *APIPortalService) GetAPIPortal(handle, orgID string) (*api.ApiPortalRes return ModelToAPIPortalResponse(portal), nil } -// ListAPIPortals returns a page of API Portals in the organization, honoring -// the requested pagination + filter args. Limit/Offset are normalized here. -// Flat args (rather than an options struct) so the method satisfies the -// pdk.APIPortals contract by shape, matches the Gateways pattern. +// ListAPIPortals returns a page of API Portals in the organization; Limit/Offset are normalized here. func (s *APIPortalService) ListAPIPortals(orgID string, limit, offset int, sortBy, sortOrder, search string) (*api.ApiPortalListResponse, error) { org, err := s.orgRepo.GetOrganizationByUUID(orgID) if err != nil { @@ -293,15 +255,7 @@ func (s *APIPortalService) ListAPIPortals(orgID string, limit, offset int, sortB return buildAPIPortalListResponse(page, PaginationInfo{Total: total, Offset: offset, Limit: limit}), nil } -// UpdateAPIPortal loads the row, applies only the whitelisted mutations from -// req, persists the change, and returns the updated row. Nil pointer fields -// on the request mean "not sent" and are passed through unchanged. -// -// sharedKey is the rotation path: when the caller supplies a new hex value on -// the wire, we replace the encrypted stored value with a fresh encryption of -// the new plaintext. When sharedKey is absent, the stored bytes are left as-is, -// this matches the "supply only the fields you want to change" contract for -// every other field on Update. +// UpdateAPIPortal applies whitelisted mutations from req; nil pointer fields mean "not sent" and are left unchanged. A non-nil SharedKey re-encrypts and rotates the stored value. func (s *APIPortalService) UpdateAPIPortal(handle string, req *api.UpdateApiPortalRequest, orgID, updatedBy string) (*api.ApiPortalResponse, error) { if req == nil { return nil, apperror.ValidationFailed.New("The request body is required.") @@ -342,7 +296,7 @@ func (s *APIPortalService) UpdateAPIPortal(handle string, req *api.UpdateApiPort portal.InternalAuthKey = encryptedKey } if req.Metadata != nil { - // Metadata is opaque pass-through; supplied map fully replaces stored. + // Supplied metadata map fully replaces stored (no per-key merge). portal.Metadata = derefAPIPortalMetadata(req.Metadata) } portal.UpdatedBy = strings.TrimSpace(updatedBy) @@ -351,13 +305,12 @@ func (s *APIPortalService) UpdateAPIPortal(handle string, req *api.UpdateApiPort return nil, err } _ = s.auditRepo.Record("UPDATE", portal.ID, "api_portal", orgID, portal.UpdatedBy) - // Config may have changed; drop any cached AuthProvider so the next - // outbound call rebuilds from the new stored values. + // Config may have changed; drop cached AuthProvider so next call rebuilds. s.invalidateCachedAuthProvider(portal.Handle, portal.OrganizationID) return ModelToAPIPortalResponse(portal), nil } -// DeleteAPIPortal removes the API Portal identified by its handle, org-scoped. +// DeleteAPIPortal removes the API Portal identified by its handle within orgID. func (s *APIPortalService) DeleteAPIPortal(handle, orgID, actor string) error { portal, err := s.portalRepo.GetByHandleAndOrgID(strings.TrimSpace(handle), orgID) if err != nil { diff --git a/platform-api/internal/service/api_portal_auth.go b/platform-api/internal/service/api_portal_auth.go index fddcb21852..7ebfa40380 100644 --- a/platform-api/internal/service/api_portal_auth.go +++ b/platform-api/internal/service/api_portal_auth.go @@ -15,18 +15,8 @@ * */ -// Shared-key outbound authentication for Platform-API → API Portal admin API -// calls. Platform-API stores each portal's shared-key encrypted-at-rest in the -// api_portals.internal_auth_key column; the outbound publisher calls -// AuthHeaderForPortal to get the "SharedKey " header value to attach to -// each publish request. The registry decrypts each portal's key exactly once -// (on first use), caches the plaintext in memory, and drops it when the row -// changes (Update / Delete). See SharedKey-Auth-Design.md for the mechanism. -// -// The registry is instantiated once at server startup and shared by every -// publisher; concurrent Gets for the same portal race safely (map is guarded -// by a mutex; a lost race is idempotent, both callers get equivalent -// providers). +// Shared-key outbound authentication for Platform-API to API Portal admin API calls. +// The registry decrypts each portal's key on first use and caches the built provider. package service @@ -41,34 +31,21 @@ import ( "github.com/wso2/api-platform/platform-api/internal/vault" ) -// AuthProvider is the outbound-auth surface exposed to any component that -// needs to call a portal's admin REST endpoints. +// AuthProvider yields the Authorization header for outbound calls to a portal's admin REST endpoints. type AuthProvider interface { - // AuthorizationHeader returns the fully-formed Authorization header value - // for the next outbound publish call to this portal, e.g. - // "SharedKey ". Provider implementations decrypt / cache the raw - // once at construction and hand out the same header for the provider's - // lifetime; rotation of the underlying stored key is handled at the - // registry level (Invalidate drops the cached provider so the next Get - // re-reads the row and re-decrypts). + // AuthorizationHeader returns the fully-formed Authorization header value (e.g. "SharedKey "). AuthorizationHeader(ctx context.Context) (string, error) - // InvalidateCache is a per-provider no-op today. Included on the - // interface so future provider implementations (e.g. one that fetches - // the raw from OpenBao on every call) can flush an in-provider cache - // without a whole-registry drop. + // InvalidateCache lets provider implementations flush an in-provider cache without a whole-registry drop. InvalidateCache() } -// sharedKeyAuthProvider serves "SharedKey " for a single portal. Fields -// are read-only after construction; safe to share across goroutines. +// sharedKeyAuthProvider serves "SharedKey " for a single portal; fields are read-only after construction. type sharedKeyAuthProvider struct { - header string // "SharedKey " — the exact bytes sent on the wire + header string // "SharedKey ", the exact bytes sent on the wire } -// NewSharedKeyAuthProvider constructs a provider from a portal's encrypted -// shared-key bytes. Decryption happens once, the plaintext lives inside the -// returned provider (never persisted, never re-encrypted). +// NewSharedKeyAuthProvider decrypts the stored key once and returns a provider that caches the resulting header. func NewSharedKeyAuthProvider(v vault.SecretVault, encryptedKey []byte) (AuthProvider, error) { if v == nil { return nil, fmt.Errorf("shared-key AuthProvider: vault is nil") @@ -90,15 +67,10 @@ func (p *sharedKeyAuthProvider) AuthorizationHeader(_ context.Context) (string, } func (p *sharedKeyAuthProvider) InvalidateCache() { - // no-op: SharedKey plaintext is fixed for a provider's lifetime; when - // the stored key rotates, the service calls registry.Invalidate which - // drops the whole provider so the next Get rebuilds from the fresh row. + // No-op: rotation is handled at the registry level by dropping the whole provider. } -// APIPortalAuthRegistry holds at most one AuthProvider per portal (keyed by -// orgID + handle). The service's Update / Delete paths call Invalidate so the -// next outbound call picks up whatever the row now says. Provider construction -// (Decrypt on the row's internal_auth_key) happens on Get miss. +// APIPortalAuthRegistry caches at most one AuthProvider per (orgID, handle); providers are built on Get miss. type APIPortalAuthRegistry struct { mu sync.Mutex providers map[string]AuthProvider // key = registryKey(orgID, handle) @@ -107,9 +79,7 @@ type APIPortalAuthRegistry struct { vault vault.SecretVault } -// NewAPIPortalAuthRegistry constructs the registry. portalRepo is used to load -// a row's internal_auth_key when Get misses the cache; vault is used to -// decrypt that value. +// NewAPIPortalAuthRegistry constructs the registry. func NewAPIPortalAuthRegistry(portalRepo repository.APIPortalRepository, v vault.SecretVault) *APIPortalAuthRegistry { return &APIPortalAuthRegistry{ providers: map[string]AuthProvider{}, @@ -119,22 +89,12 @@ func NewAPIPortalAuthRegistry(portalRepo repository.APIPortalRepository, v vault } } -// registryKey composes a stable per-(org, portal) cache key. Same handle in -// different orgs get different entries so a Get for one org never returns -// another org's provider — the DB row lookup would fail cross-org anyway -// (GetByHandleAndOrgID filters on organization_uuid), but keeping the cache -// keyed on both means the miss path stays correct without racing. +// registryKey composes a stable per-(org, portal) cache key so cross-org lookups never collide. func registryKey(orgID, portalHandle string) string { return orgID + "/" + portalHandle } -// Invalidate drops the cached provider for a portal handle in an org. No-op -// when there is no cached entry (idempotent, safe to call from Delete paths). -// Called by the service on every Update / Delete of a portal row. -// -// Bumps the per-key generation so a concurrent Get that has already read the -// pre-Invalidate row does not repopulate the cache with the stale provider -// after Invalidate returns. +// Invalidate drops the cached provider and bumps the generation so a concurrent Get cannot repopulate a stale entry. func (r *APIPortalAuthRegistry) Invalidate(portalHandle, orgID string) { if r == nil { return @@ -146,19 +106,7 @@ func (r *APIPortalAuthRegistry) Invalidate(portalHandle, orgID string) { r.generations[key]++ } -// Get returns the AuthProvider for the (org, portal) pair, constructing + -// caching on first call. Returns APIPortalNotFound when the row is not -// present. Any decryption failure (row bytes not encrypted with the current -// vault key, or corrupted ciphertext) surfaces as a plain error the caller -// treats as a permanent configuration problem. -// -// Concurrent Gets for the same key race safely: the first one wins the map -// slot, subsequent ones return that stored provider. If Invalidate runs -// between the row read and the cache fill, the per-key generation counter -// no longer matches the snapshot and the cache write is skipped, so an -// invalidated key never re-appears in the cache from an in-flight Get. This -// call still returns the just-built provider (built from the row state we -// read); the next Get rebuilds from the updated row. +// Get returns the AuthProvider for the (org, portal) pair, constructing and caching on first call. func (r *APIPortalAuthRegistry) Get(portalHandle, orgID string) (AuthProvider, error) { if r == nil { return nil, fmt.Errorf("shared-key AuthProvider registry is not initialised") @@ -189,8 +137,7 @@ func (r *APIPortalAuthRegistry) Get(portalHandle, orgID string) (AuthProvider, e r.mu.Lock() defer r.mu.Unlock() if r.generations[key] != genSnapshot { - // Invalidate ran while we were decrypting. Do not cache; caller uses - // the provider it has (built from the pre-Invalidate row). + // Invalidate ran during decrypt; skip cache write so a stale provider never sticks. return provider, nil } if existing, ok := r.providers[key]; ok { diff --git a/platform-api/internal/service/api_portal_translate.go b/platform-api/internal/service/api_portal_translate.go index 67749eabd3..4dbd40ebb0 100644 --- a/platform-api/internal/service/api_portal_translate.go +++ b/platform-api/internal/service/api_portal_translate.go @@ -22,11 +22,9 @@ import ( "github.com/wso2/api-platform/platform-api/internal/model" ) -// APIPortal DTO <-> model translation, shared between the HTTP handler and the -// pdk-facing wrappers on APIPortalService. +// APIPortal DTO <-> model translation. -// derefAPIPortalMetadata converts the generated Metadata type (a map alias) -// into a plain map[string]interface{} the service works in. Nil in -> nil out. +// derefAPIPortalMetadata converts the generated Metadata alias into a plain map; nil in -> nil out. func derefAPIPortalMetadata(m *api.ApiPortalMetadata) map[string]interface{} { if m == nil { return nil @@ -34,12 +32,7 @@ func derefAPIPortalMetadata(m *api.ApiPortalMetadata) map[string]interface{} { return map[string]interface{}(*m) } -// ModelToAPIPortalResponse converts an internal model.APIPortal into the -// api-generated ApiPortalResponse. Exported so the HTTP handler can serialize -// what the service returns. The InternalAuthKey field is NEVER surfaced, -// the only path for a client to see the shared key is the write-only field -// on Create/Update requests, and that value is not stored in a form that can -// be re-read. +// ModelToAPIPortalResponse converts a model.APIPortal into the wire response; the shared key is never surfaced. func ModelToAPIPortalResponse(p *model.APIPortal) *api.ApiPortalResponse { if p == nil { return nil @@ -68,8 +61,7 @@ func ModelToAPIPortalResponse(p *model.APIPortal) *api.ApiPortalResponse { return resp } -// modelToAPIPortalListItem projects a model.APIPortal onto the list-response -// item type (excludes metadata by design, and never carries the shared key). +// modelToAPIPortalListItem projects a model.APIPortal onto the list-response item (metadata and shared key are excluded). func modelToAPIPortalListItem(p *model.APIPortal) api.ApiPortalListItem { item := api.ApiPortalListItem{ Id: p.Handle, @@ -85,8 +77,7 @@ func modelToAPIPortalListItem(p *model.APIPortal) api.ApiPortalListItem { return item } -// buildAPIPortalListResponse wraps the raw list + pagination info in the -// api-generated ApiPortalListResponse envelope. +// buildAPIPortalListResponse wraps the page + pagination info in the wire envelope. func buildAPIPortalListResponse(list []*model.APIPortal, pag PaginationInfo) *api.ApiPortalListResponse { out := &api.ApiPortalListResponse{ Count: len(list), diff --git a/platform-api/pdk/deps.go b/platform-api/pdk/deps.go index d1947bf9e5..7435951804 100644 --- a/platform-api/pdk/deps.go +++ b/platform-api/pdk/deps.go @@ -81,33 +81,12 @@ type Projects interface { DeleteProject(handle, orgID, actor string) error } -// APIPortals exposes CRUD access to the platform's API portals, scoped by -// organization. Every method mirrors an existing APIPortalService method verbatim -// and takes the organization id explicitly — handlers MUST pass the org resolved -// from the request context, never one from request input (GO-AUTH-005). -// -// Portals are the outbound-publish target for APIs, MCP servers, and -// subscription plans. Plugins consume this capability when they need to -// register or manage a portal record on top of the platform's core row -// (e.g. the cloud plugin's /managed-api-portals resource, which layers -// runtime provisioning + DCR-app management on top of the same row). +// APIPortals exposes CRUD on API Portal records, scoped by organization. +// orgID is always the request-context org (GO-AUTH-005), never caller input. type APIPortals interface { - // CreateAPIPortal registers an API Portal in an organization (Create). CreateAPIPortal(req *api.CreateApiPortalRequest, orgID, createdBy string) (*api.ApiPortalResponse, error) - - // GetAPIPortal returns a single API Portal by its handle within an - // organization (Read). GetAPIPortal(handle, orgID string) (*api.ApiPortalResponse, error) - - // ListAPIPortals returns a page of API Portals in an organization (Read). - // limit/offset are normalized inside the service; sortBy/sortOrder/search - // map to the same OpenAPI query parameters the native handler exposes. ListAPIPortals(orgID string, limit, offset int, sortBy, sortOrder, search string) (*api.ApiPortalListResponse, error) - - // UpdateAPIPortal updates the whitelisted mutable fields on an API Portal - // within an organization (Update). UpdateAPIPortal(handle string, req *api.UpdateApiPortalRequest, orgID, updatedBy string) (*api.ApiPortalResponse, error) - - // DeleteAPIPortal removes an API Portal within an organization (Delete). DeleteAPIPortal(handle, orgID, actor string) error }