diff --git a/platform-api/api/generated.go b/platform-api/api/generated.go index e5050eb044..51c2e13b99 100644 --- a/platform-api/api/generated.go +++ b/platform-api/api/generated.go @@ -358,6 +358,18 @@ 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 ListApplicationsParamsSortBy. const ( ListApplicationsParamsSortByCreatedAt ListApplicationsParamsSortBy = "createdAt" @@ -433,14 +445,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 +558,49 @@ type AddGatewayToRESTAPIRequest struct { GatewayId string `binding:"required" json:"gatewayId" yaml:"gatewayId"` } +// ApiPortalListItem Lightweight projection returned in collection responses (excludes the metadata blob). +type ApiPortalListItem struct { + 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"` +} + +// 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"` +} + +// 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 { + 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"` + + // 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"` + + // Url Public URL of the API Portal. Operator-supplied. + Url string `binding:"required" json:"url" yaml:"url"` +} + // Application defines model for Application. type Application struct { CreatedAt *time.Time `json:"createdAt,omitempty" yaml:"createdAt,omitempty"` @@ -718,6 +773,24 @@ type CreateAPIKeyResponse struct { // CreateAPIKeyResponseStatus Status of the operation type CreateAPIKeyResponseStatus string +// CreateApiPortalRequest defines model for CreateApiPortalRequest. +type CreateApiPortalRequest struct { + 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"` + + // 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"` + + // 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"` +} + // CreateApplicationRequest Request body for creating an application. type CreateApplicationRequest struct { // Description Description of the application @@ -1312,7 +1385,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 +1891,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 +2338,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 +2406,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 +2622,19 @@ 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 { + 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"` + + // 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 { // Main Upstream endpoint configuration. Provide exactly one of `url` (a direct backend URL) or @@ -2644,6 +2739,9 @@ type UserAPIKeyListResponse struct { // ApiId defines model for apiId. type ApiId = string +// ApiPortalId defines model for apiPortalId. +type ApiPortalId = string + // AppId defines model for appId. type AppId = string @@ -2719,6 +2817,30 @@ 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"` +} + +// ListApiPortalsParamsSortBy defines parameters for ListApiPortals. +type ListApiPortalsParamsSortBy string + +// ListApiPortalsParamsSortOrder defines parameters for ListApiPortals. +type ListApiPortalsParamsSortOrder 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 +3005,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 +3065,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 +3107,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 +3213,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 +3310,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 +3427,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/constants/constants.go b/platform-api/internal/constants/constants.go index 00330f75ae..534cf771ba 100644 --- a/platform-api/internal/constants/constants.go +++ b/platform-api/internal/constants/constants.go @@ -207,6 +207,23 @@ var ValidGatewayTokenStatuses = map[string]bool{ GatewayTokenStatusRevoked: true, } +// API Portal status constants. +const ( + APIPortalStatusPending = "pending" + APIPortalStatusActive = "active" + APIPortalStatusFailed = "failed" +) + +// 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; matched case-insensitively by the portal. + APIPortalSharedKeyAuthScheme = "SharedKey" + + // APIPortalSharedKeyHexLength is the required raw-key length in hex chars (32 bytes of entropy). + APIPortalSharedKeyHexLength = 64 +) + // 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 a37fb640bc..6f0ecb3978 100644 --- a/platform-api/internal/database/schema.postgres.sql +++ b/platform-api/internal/database/schema.postgres.sql @@ -399,6 +399,25 @@ 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), + status VARCHAR(20) NOT NULL DEFAULT 'pending', + internal_auth_key BYTEA NOT NULL, + metadata BYTEA, + 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 +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_applications_org ON applications(organization_uuid); CREATE INDEX IF NOT EXISTS idx_applications_project_id ON applications(organization_uuid, project_uuid); diff --git a/platform-api/internal/database/schema.sqlite.sql b/platform-api/internal/database/schema.sqlite.sql index 9f009f6262..3c9d84b6b0 100644 --- a/platform-api/internal/database/schema.sqlite.sql +++ b/platform-api/internal/database/schema.sqlite.sql @@ -399,6 +399,25 @@ 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), + status VARCHAR(20) NOT NULL DEFAULT 'pending', + internal_auth_key BLOB NOT NULL, + metadata BLOB, + 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 +491,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..7f25933c1c 100644 --- a/platform-api/internal/database/schema.sqlserver.sql +++ b/platform-api/internal/database/schema.sqlserver.sql @@ -450,6 +450,26 @@ 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), + status VARCHAR(20) NOT NULL DEFAULT 'pending', + internal_auth_key VARBINARY(MAX) NOT NULL, + metadata VARBINARY(MAX), + 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 +574,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')) diff --git a/platform-api/internal/handler/api_portal.go b/platform-api/internal/handler/api_portal.go new file mode 100644 index 0000000000..e34e90ae60 --- /dev/null +++ b/platform-api/internal/handler/api_portal.go @@ -0,0 +1,183 @@ +/* + * 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/router" + "github.com/wso2/api-platform/platform-api/internal/service" + + "github.com/wso2/api-platform/httpkit/httputil" +) + +// APIPortalHandler exposes /api-portals CRUD. +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 handles 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 + } + + 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", req.Handle, orgID, createdBy)) + } + + setLocation(w, "api-portals", derefStr(resp.Handle)) + httputil.WriteJSON(w, http.StatusCreated, resp) + return nil +} + +// GetAPIPortal handles 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") + } + + 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, resp) + return nil +} + +// ListAPIPortals handles 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 := parseListOptions(r) + + 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, resp) + return nil +} + +// UpdateAPIPortal handles 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 + } + + 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, resp) + return nil +} + +// DeleteAPIPortal handles 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)) +} + +// derefStr returns the pointed-to string or "" when nil. +func derefStr(p *string) string { + if p == nil { + return "" + } + return *p +} 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/model/api_portal.go b/platform-api/internal/model/api_portal.go new file mode 100644 index 0000000000..a171bd78ed --- /dev/null +++ b/platform-api/internal/model/api_portal.go @@ -0,0 +1,63 @@ +/* + * 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. +// 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"` + 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. +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.Status == constants.APIPortalStatusPending +} + +// IsActive returns true if the portal is reachable and functional. +func (p *APIPortal) IsActive() bool { + return p.Status == constants.APIPortalStatusActive +} + +// IsFailed returns true if provisioning or a subsequent health check has failed. +func (p *APIPortal) IsFailed() bool { + return p.Status == constants.APIPortalStatusFailed +} diff --git a/platform-api/internal/repository/api_portal.go b/platform-api/internal/repository/api_portal.go new file mode 100644 index 0000000000..816ca106f0 --- /dev/null +++ b/platform-api/internal/repository/api_portal.go @@ -0,0 +1,268 @@ +/* + * 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, + status, internal_auth_key, metadata, + 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 metadataBytes []byte + if err := scanner.Scan( + &portal.ID, &portal.OrganizationID, &portal.Handle, &portal.Name, &description, &url, + &portal.Status, &portal.InternalAuthKey, &metadataBytes, + &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 + metadata, err := unmarshalAPIPortalBlob(metadataBytes, "metadata") + if err != nil { + return nil, err + } + portal.Metadata = metadata + return portal, nil +} + +// 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 + } + b, err := json.Marshal(m) + if err != nil { + return nil, fmt.Errorf("failed to marshal %s: %w", field, err) + } + return b, nil +} + +// 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 + } + 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 +} + +// 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 + 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, internal_auth_key, 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.Status, portal.InternalAuthKey, metadataBytes, + 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. +func (r *APIPortalRepo) ListPaginated(orgUUID string, opts ListOptions) ([]*model.APIPortal, error) { + var args []interface{} + conditions := []string{`organization_uuid = ?`} + args = append(args, orgUUID) + 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 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 = ?`} + args = append(args, orgUUID) + 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 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") + if err != nil { + return err + } + query := ` + UPDATE api_portals + SET display_name = ?, description = ?, url = ?, status = ?, + 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.InternalAuthKey, metadataBytes, + 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, 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) + 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..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/repository/interfaces.go b/platform-api/internal/repository/interfaces.go index 13354e371d..23ffc33e70 100644 --- a/platform-api/internal/repository/interfaces.go +++ b/platform-api/internal/repository/interfaces.go @@ -294,6 +294,18 @@ type LLMProxyRepository interface { EnsureGatewayAssociation(proxyUUID, gatewayUUID, orgUUID, createdBy, deployMetadata string, metadataProvided bool) (string, error) } +// APIPortalRepository defines the interface for API Portal persistence. +type APIPortalRepository interface { + Create(portal *model.APIPortal) error + GetByUUID(portalID, orgUUID string) (*model.APIPortal, error) + GetByHandleAndOrgID(handle, orgUUID string) (*model.APIPortal, error) + ListPaginated(orgUUID string, opts ListOptions) ([]*model.APIPortal, error) + Count(orgUUID 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 diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index bfc443a1ac..da177e8101 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -141,6 +141,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) @@ -325,6 +326,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) + apiPortalAuthRegistry := service.NewAPIPortalAuthRegistry(apiPortalRepo, secretVault) + apiPortalService := service.NewAPIPortalService(apiPortalRepo, orgRepo, auditRepo, secretVault, apiPortalAuthRegistry, identityService, slogger) // Initialize handlers orgHandler := handler.NewOrganizationHandler(orgService, identityService, cfg.Auth.Authorization.Mode, slogger) @@ -334,6 +337,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) @@ -389,6 +393,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) @@ -436,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 new file mode 100644 index 0000000000..e6040e0594 --- /dev/null +++ b/platform-api/internal/service/api_portal.go @@ -0,0 +1,328 @@ +/* + * 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" + "fmt" + "log/slog" + "net/url" + "regexp" + "strings" + + "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" + "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 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 == "" { + 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 +} + +// 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 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 == "" { + return nil, apperror.ValidationFailed.New("The sharedKey field is required.") + } + 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`).") + } + ciphertext, err := v.Encrypt(context.Background(), trimmed) + if err != nil { + return nil, err + } + return ciphertext, nil +} + +// APIPortalService encapsulates business logic for the /api-portals resource. +type APIPortalService struct { + portalRepo repository.APIPortalRepository + orgRepo repository.OrganizationRepository + auditRepo repository.AuditRepository + vault vault.SecretVault + authRegistry *APIPortalAuthRegistry + identity *IdentityService + slogger *slog.Logger +} + +// NewAPIPortalService constructs an APIPortalService. +func NewAPIPortalService( + portalRepo repository.APIPortalRepository, + 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, + authRegistry: authRegistry, + identity: identity, + slogger: slogger, + } +} + +// 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 + } + s.authRegistry.Invalidate(portalHandle, orgID) +} + +// 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") + } + 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 list-response envelope. +type PaginationInfo struct { + Total int + Offset int + Limit int +} + +func derefStr(p *string) string { + if p == nil { + return "" + } + return *p +} + +// 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.") + } + 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 + } + portalURL, err := validateAPIPortalURL(req.Url) + if err != nil { + return nil, err + } + if portalURL == "" { + return nil, apperror.ValidationFailed.New("The url field is required.") + } + encryptedKey, err := validateAndEncryptSharedKey(s.vault, derefStr(req.SharedKey)) + if err != nil { + return nil, err + } + + 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(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 { + if repository.IsUniqueViolation(err) { + // 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 ModelToAPIPortalResponse(portal), nil +} + +// 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 { + return nil, err + } + if portal == nil { + return nil, apperror.APIPortalNotFound.New() + } + return ModelToAPIPortalResponse(portal), nil +} + +// 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 { + return nil, err + } + if org == nil { + return nil, apperror.OrganizationNotFound.New() + } + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + if offset < 0 { + offset = 0 + } + total, err := s.portalRepo.Count(orgID, search) + if err != nil { + return nil, err + } + 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 buildAPIPortalListResponse(page, PaginationInfo{Total: total, Offset: offset, Limit: limit}), nil +} + +// 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.") + } + 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 { + portalURL, err := validateAPIPortalURL(*req.Url) + if err != nil { + return nil, err + } + if portalURL == "" { + return nil, apperror.ValidationFailed.New("The url field cannot be empty.") + } + portal.URL = portalURL + } + if req.SharedKey != nil { + encryptedKey, err := validateAndEncryptSharedKey(s.vault, *req.SharedKey) + if err != nil { + return nil, err + } + portal.InternalAuthKey = encryptedKey + } + if req.Metadata != nil { + // Supplied metadata map fully replaces stored (no per-key merge). + portal.Metadata = derefAPIPortalMetadata(req.Metadata) + } + 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) + // 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 within orgID. +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)) + 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 new file mode 100644 index 0000000000..7ebfa40380 --- /dev/null +++ b/platform-api/internal/service/api_portal_auth.go @@ -0,0 +1,148 @@ +/* + * 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. + * + */ + +// 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 + +import ( + "context" + "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 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 (e.g. "SharedKey "). + AuthorizationHeader(ctx context.Context) (string, error) + + // 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. +type sharedKeyAuthProvider struct { + header string // "SharedKey ", the exact bytes sent on the wire +} + +// 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") + } + 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: rotation is handled at the registry level by dropping the whole provider. +} + +// 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) + generations map[string]uint64 // bumped by Invalidate; guards cache-fill races + portalRepo repository.APIPortalRepository + vault vault.SecretVault +} + +// NewAPIPortalAuthRegistry constructs the registry. +func NewAPIPortalAuthRegistry(portalRepo repository.APIPortalRepository, v vault.SecretVault) *APIPortalAuthRegistry { + return &APIPortalAuthRegistry{ + providers: map[string]AuthProvider{}, + generations: map[string]uint64{}, + portalRepo: portalRepo, + vault: v, + } +} + +// 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 and bumps the generation so a concurrent Get cannot repopulate a stale entry. +func (r *APIPortalAuthRegistry) Invalidate(portalHandle, orgID string) { + if r == nil { + return + } + r.mu.Lock() + defer r.mu.Unlock() + key := registryKey(orgID, portalHandle) + delete(r.providers, key) + r.generations[key]++ +} + +// 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") + } + key := registryKey(orgID, portalHandle) + + r.mu.Lock() + if p, ok := r.providers[key]; ok { + r.mu.Unlock() + return p, nil + } + genSnapshot := r.generations[key] + 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() + defer r.mu.Unlock() + if r.generations[key] != genSnapshot { + // Invalidate ran during decrypt; skip cache write so a stale provider never sticks. + return provider, nil + } + if existing, ok := r.providers[key]; ok { + return existing, nil + } + r.providers[key] = provider + return provider, 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..723b1d66b0 --- /dev/null +++ b/platform-api/internal/service/api_portal_test.go @@ -0,0 +1,780 @@ +/* + * 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" + "context" + "errors" + "strings" + "testing" + "time" + + "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) + } +} + +// --- 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/internal/service/api_portal_translate.go b/platform-api/internal/service/api_portal_translate.go new file mode 100644 index 0000000000..4dbd40ebb0 --- /dev/null +++ b/platform-api/internal/service/api_portal_translate.go @@ -0,0 +1,95 @@ +/* + * 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/model" +) + +// APIPortal DTO <-> model translation. + +// 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 + } + return map[string]interface{}(*m) +} + +// 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 + } + 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, + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + } + if p.Description != "" { + desc := p.Description + resp.Description = &desc + } + if p.Metadata != nil { + m := api.ApiPortalMetadata(p.Metadata) + resp.Metadata = &m + } + return resp +} + +// 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, + Handle: p.Handle, + Name: p.Name, + Url: p.URL, + CreatedAt: p.CreatedAt, + } + if p.Description != "" { + desc := p.Description + item.Description = &desc + } + return item +} + +// 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), + 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..7435951804 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,13 @@ type Projects interface { // DeleteProject removes a project within an organization (Delete). DeleteProject(handle, orgID, actor string) error } + +// 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(req *api.CreateApiPortalRequest, orgID, createdBy string) (*api.ApiPortalResponse, error) + GetAPIPortal(handle, orgID string) (*api.ApiPortalResponse, error) + ListAPIPortals(orgID string, limit, offset int, sortBy, sortOrder, search string) (*api.ApiPortalListResponse, error) + UpdateAPIPortal(handle string, req *api.UpdateApiPortalRequest, orgID, updatedBy string) (*api.ApiPortalResponse, error) + DeleteAPIPortal(handle, orgID, actor string) error +} diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index 46f76544e1..e79eae21d1 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -4455,6 +4455,172 @@ 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 against an + existing portal URL. The URL is required. 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' + 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 +4898,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 +8821,192 @@ components: pagination: $ref: '#/components/schemas/Pagination' + 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: + title: API Portal detail + type: object + required: + - id + - name + - handle + - url + - 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: 1023 + url: + type: string + format: uri + description: Public URL of the API Portal. Operator-supplied. + example: "https://acme-portal.example.com" + metadata: + $ref: '#/components/schemas/ApiPortalMetadata' + 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 metadata blob). + type: object + required: + - id + - name + - handle + - url + - 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 + createdAt: + type: string + format: date-time + + CreateApiPortalRequest: + title: Create API Portal request + type: object + required: + - name + - handle + - url + - sharedKey + 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: 1023 + url: + type: string + format: uri + description: Public URL of the API Portal to register. Operator-supplied. + sharedKey: + type: string + 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. + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 255 + description: + type: string + nullable: true + maxLength: 1023 + url: + type: string + format: uri + sharedKey: + type: string + 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' + + 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 +9348,19 @@ 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" + tags: - name: Health description: Health check endpoints @@ -9004,10 +9374,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: DevPortals - description: DevPortal management operations + - name: API Portals + description: API Portal registration and management - name: Gateways description: Gateway registration and management operations - name: Gateway Tokens 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