From b1c9e638f1be7ca0f1f72cc032dc923e714013bc Mon Sep 17 00:00:00 2001 From: Ashera Silva Date: Wed, 15 Jul 2026 16:28:10 +0530 Subject: [PATCH 1/3] Fix audit-identity gaps: updated_by, org membership, UTC timestamps --- .../internal/database/schema.postgres.sql | 14 +- platform-api/internal/database/schema.sql | 4 +- .../internal/database/schema.sqlite.sql | 4 +- platform-api/internal/handler/api.go | 7 +- .../internal/handler/gateway_internal.go | 7 +- .../internal/handler/llm_deployment.go | 24 +- platform-api/internal/handler/organization.go | 20 +- .../handler/organization_integration_test.go | 228 +++++++ platform-api/internal/handler/secret.go | 7 +- .../integration/audit_identity_it_test.go | 567 ++++++++++++++++++ .../internal/integration/crud_test.go | 9 +- platform-api/internal/middleware/auth.go | 10 +- .../internal/middleware/authorization.go | 13 + platform-api/internal/model/api.go | 2 + platform-api/internal/model/apikey.go | 1 + .../model/user_organization_mapping.go | 11 +- platform-api/internal/repository/api.go | 42 +- platform-api/internal/repository/api_test.go | 133 ++++ platform-api/internal/repository/apikey.go | 52 +- .../internal/repository/apikey_test.go | 158 +++++ .../internal/repository/application.go | 8 +- platform-api/internal/repository/audit.go | 2 +- .../internal/repository/custom_policy.go | 4 +- .../internal/repository/deployment.go | 15 +- platform-api/internal/repository/gateway.go | 14 +- .../internal/repository/gateway_test.go | 86 +++ .../internal/repository/interfaces.go | 20 +- platform-api/internal/repository/llm.go | 110 ++-- .../internal/repository/llm_audit_test.go | 163 +++++ platform-api/internal/repository/mcp.go | 8 +- .../internal/repository/organization.go | 55 +- platform-api/internal/repository/project.go | 6 +- platform-api/internal/repository/secret.go | 6 +- .../internal/repository/secret_test.go | 7 +- .../subscription_plan_repository.go | 4 +- .../repository/subscription_repository.go | 6 +- .../repository/user_identity_mapping.go | 2 +- .../repository/user_organization_mapping.go | 16 +- .../user_organization_mapping_test.go | 231 +++++++ platform-api/internal/server/server.go | 6 +- platform-api/internal/service/api.go | 48 +- platform-api/internal/service/apikey.go | 4 +- platform-api/internal/service/apikey_user.go | 11 +- platform-api/internal/service/application.go | 24 +- .../internal/service/artifact_import.go | 3 - platform-api/internal/service/deployment.go | 13 +- .../internal/service/deployment_test.go | 62 +- platform-api/internal/service/gateway.go | 24 +- .../service/identity_deleted_user_test.go | 419 +++++++++++++ platform-api/internal/service/llm.go | 58 +- platform-api/internal/service/llm_apikey.go | 17 +- .../internal/service/llm_deployment.go | 20 +- .../internal/service/llm_proxy_apikey.go | 1 + platform-api/internal/service/mcp.go | 43 +- .../internal/service/mcp_deployment.go | 8 +- platform-api/internal/service/organization.go | 67 ++- .../internal/service/organization_test.go | 213 +++++++ platform-api/internal/service/project.go | 25 +- .../eventgateway/repository/webbroker_api.go | 6 +- .../eventgateway/repository/websub_api.go | 6 +- .../eventgateway/service/webbroker_api.go | 29 +- .../service/webbroker_api_deployment.go | 8 +- .../eventgateway/service/websub_api.go | 28 +- .../service/websub_api_deployment.go | 8 +- portals/ai-workspace/src/apis/platformApis.ts | 22 - .../src/contexts/ChoreoUserContext.tsx | 70 ++- .../ExternalServersOverview.tsx | 11 +- .../providerTemplate/EditProviderTemplate.tsx | 2 +- .../ProviderTemplateOverview.tsx | 9 + .../appShellPages/proxies/EditLLMProxy.tsx | 1 + .../proxies/LLMProxyOverview.tsx | 14 + .../serviceProvider/EditServiceProvider.tsx | 1 + .../ServiceProviderConnectionTab.tsx | 4 + .../ServiceProviderGuardrailsTab.tsx | 2 + .../ServiceProviderModelsTab.tsx | 1 + .../ServiceProviderOverview.tsx | 20 +- .../ServiceProviderRateLimitingTab.tsx | 1 + .../ServiceProviderResourcesTab.tsx | 1 + .../ServiceProviderSecurityTab.tsx | 1 + portals/ai-workspace/src/utils/types.ts | 11 +- 80 files changed, 2979 insertions(+), 419 deletions(-) create mode 100644 platform-api/internal/handler/organization_integration_test.go create mode 100644 platform-api/internal/integration/audit_identity_it_test.go create mode 100644 platform-api/internal/repository/apikey_test.go create mode 100644 platform-api/internal/repository/gateway_test.go create mode 100644 platform-api/internal/repository/llm_audit_test.go create mode 100644 platform-api/internal/repository/user_organization_mapping_test.go create mode 100644 platform-api/internal/service/identity_deleted_user_test.go create mode 100644 platform-api/internal/service/organization_test.go diff --git a/platform-api/internal/database/schema.postgres.sql b/platform-api/internal/database/schema.postgres.sql index 5a037c0135..a37fb640bc 100644 --- a/platform-api/internal/database/schema.postgres.sql +++ b/platform-api/internal/database/schema.postgres.sql @@ -214,9 +214,9 @@ CREATE TABLE IF NOT EXISTS artifact_gateway_mappings ( gateway_uuid VARCHAR(40) NOT NULL, metadata BYTEA, created_by VARCHAR(200), - created_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(200), - updated_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (organization_uuid, artifact_uuid, gateway_uuid), FOREIGN KEY (artifact_uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE, FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, @@ -540,9 +540,9 @@ CREATE TABLE IF NOT EXISTS secrets ( type VARCHAR(20) NOT NULL DEFAULT 'GENERIC', provider VARCHAR(20) NOT NULL DEFAULT 'IN_BUILT', status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', - created_at TIMESTAMP NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by VARCHAR(255), - updated_at TIMESTAMP NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255), UNIQUE (organization_uuid, handle), FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE @@ -568,7 +568,7 @@ CREATE TABLE IF NOT EXISTS artifact_secret_refs ( artifact_uuid VARCHAR(40) NOT NULL, secret_handle VARCHAR(40) NOT NULL, gateway_id VARCHAR(40) NOT NULL DEFAULT '', - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (organization_uuid, artifact_uuid, secret_handle, gateway_id), FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, FOREIGN KEY (artifact_uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE @@ -587,7 +587,7 @@ CREATE INDEX IF NOT EXISTS idx_asr_org_gateway CREATE TABLE IF NOT EXISTS user_idp_references ( uuid VARCHAR(40) PRIMARY KEY, idp_id VARCHAR(255) NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, UNIQUE(idp_id) ); @@ -595,7 +595,7 @@ CREATE TABLE IF NOT EXISTS user_idp_references ( CREATE TABLE IF NOT EXISTS user_organization_mappings ( user_uuid VARCHAR(40) NOT NULL, org_uuid VARCHAR(40) NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_uuid, org_uuid), FOREIGN KEY (user_uuid) REFERENCES user_idp_references(uuid) ON DELETE CASCADE, FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE diff --git a/platform-api/internal/database/schema.sql b/platform-api/internal/database/schema.sql index f967ba8283..f360c3f0f3 100644 --- a/platform-api/internal/database/schema.sql +++ b/platform-api/internal/database/schema.sql @@ -535,9 +535,9 @@ CREATE TABLE IF NOT EXISTS secrets ( type VARCHAR(20) NOT NULL DEFAULT 'GENERIC', provider VARCHAR(20) NOT NULL DEFAULT 'IN_BUILT', status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', - created_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by VARCHAR(255), - updated_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255), UNIQUE (organization_uuid, handle), FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE diff --git a/platform-api/internal/database/schema.sqlite.sql b/platform-api/internal/database/schema.sqlite.sql index ed301371e8..9f009f6262 100644 --- a/platform-api/internal/database/schema.sqlite.sql +++ b/platform-api/internal/database/schema.sqlite.sql @@ -540,9 +540,9 @@ CREATE TABLE IF NOT EXISTS secrets ( type VARCHAR(20) NOT NULL DEFAULT 'GENERIC', provider VARCHAR(20) NOT NULL DEFAULT 'IN_BUILT', status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', - created_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by VARCHAR(255), - updated_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255), UNIQUE (organization_uuid, handle), FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE diff --git a/platform-api/internal/handler/api.go b/platform-api/internal/handler/api.go index d79c553148..415716987c 100644 --- a/platform-api/internal/handler/api.go +++ b/platform-api/internal/handler/api.go @@ -238,7 +238,12 @@ func (h *APIHandler) AddGatewaysToAPI(w http.ResponseWriter, r *http.Request) er gatewayIds[i] = gw.GatewayId } - gatewaysResponse, err := h.apiService.AddGatewaysToAPIByHandle(apiId, gatewayIds, orgId) + createdBy, err := resolveActorErr(r, h.identity, "associate gateways with API") + if err != nil { + return err + } + + gatewaysResponse, err := h.apiService.AddGatewaysToAPIByHandle(apiId, gatewayIds, orgId, createdBy) if err != nil { return serviceError(err, fmt.Sprintf("failed to associate gateways with API %s in org %s", apiId, orgId)) } diff --git a/platform-api/internal/handler/gateway_internal.go b/platform-api/internal/handler/gateway_internal.go index e020ff90ae..40cb908249 100644 --- a/platform-api/internal/handler/gateway_internal.go +++ b/platform-api/internal/handler/gateway_internal.go @@ -914,7 +914,12 @@ func (h *GatewayInternalAPIHandler) GetGatewaySecrets(w http.ResponseWriter, r * "Invalid 'updatedAfter' parameter. Expected RFC3339 format.")) return } - updatedAfter = &t + // Normalize to UTC so the comparison against UTC-stored updated_at + // values is a correct chronological comparison regardless of the + // offset the client sent (some drivers compare timestamp bind + // parameters as text, where mixed offsets sort incorrectly). + utc := t.UTC() + updatedAfter = &utc } includeValues := r.URL.Query().Get("includeValues") == "true" diff --git a/platform-api/internal/handler/llm_deployment.go b/platform-api/internal/handler/llm_deployment.go index 8c8d607921..52f151ac09 100644 --- a/platform-api/internal/handler/llm_deployment.go +++ b/platform-api/internal/handler/llm_deployment.go @@ -37,6 +37,7 @@ import ( // using the shared deployment model. type LLMProviderDeploymentHandler struct { deploymentService *service.LLMProviderDeploymentService + identity *service.IdentityService slogger *slog.Logger } @@ -44,15 +45,16 @@ type LLMProviderDeploymentHandler struct { // using the shared deployment model. type LLMProxyDeploymentHandler struct { deploymentService *service.LLMProxyDeploymentService + identity *service.IdentityService slogger *slog.Logger } -func NewLLMProviderDeploymentHandler(deploymentService *service.LLMProviderDeploymentService, slogger *slog.Logger) *LLMProviderDeploymentHandler { - return &LLMProviderDeploymentHandler{deploymentService: deploymentService, slogger: slogger} +func NewLLMProviderDeploymentHandler(deploymentService *service.LLMProviderDeploymentService, identity *service.IdentityService, slogger *slog.Logger) *LLMProviderDeploymentHandler { + return &LLMProviderDeploymentHandler{deploymentService: deploymentService, identity: identity, slogger: slogger} } -func NewLLMProxyDeploymentHandler(deploymentService *service.LLMProxyDeploymentService, slogger *slog.Logger) *LLMProxyDeploymentHandler { - return &LLMProxyDeploymentHandler{deploymentService: deploymentService, slogger: slogger} +func NewLLMProxyDeploymentHandler(deploymentService *service.LLMProxyDeploymentService, identity *service.IdentityService, slogger *slog.Logger) *LLMProxyDeploymentHandler { + return &LLMProxyDeploymentHandler{deploymentService: deploymentService, identity: identity, slogger: slogger} } // DeployLLMProvider handles POST /api/v0.9/llm-providers/{llmProviderId}/deployments @@ -84,7 +86,12 @@ func (h *LLMProviderDeploymentHandler) DeployLLMProvider(w http.ResponseWriter, return apperror.LLMProviderDeploymentValidationFailed.New("gatewayId is required") } - deployment, err := h.deploymentService.DeployLLMProvider(providerId, &req, orgId) + createdBy, err := resolveActorErr(r, h.identity, "deploy LLM provider") + if err != nil { + return err + } + + deployment, err := h.deploymentService.DeployLLMProvider(providerId, &req, orgId, createdBy) if err != nil { return serviceError(err, fmt.Sprintf("failed to deploy LLM provider %s", providerId)) } @@ -272,7 +279,12 @@ func (h *LLMProxyDeploymentHandler) DeployLLMProxy(w http.ResponseWriter, r *htt return apperror.LLMProxyDeploymentValidationFailed.New("gatewayId is required") } - deployment, err := h.deploymentService.DeployLLMProxy(proxyId, &req, orgId) + createdBy, err := resolveActorErr(r, h.identity, "deploy LLM proxy") + if err != nil { + return err + } + + deployment, err := h.deploymentService.DeployLLMProxy(proxyId, &req, orgId, createdBy) if err != nil { return serviceError(err, fmt.Sprintf("failed to deploy LLM proxy %s", proxyId)) } diff --git a/platform-api/internal/handler/organization.go b/platform-api/internal/handler/organization.go index aeeac1c7ff..b30f2acfa8 100644 --- a/platform-api/internal/handler/organization.go +++ b/platform-api/internal/handler/organization.go @@ -36,13 +36,15 @@ import ( type OrganizationHandler struct { orgService *service.OrganizationService identity *service.IdentityService + authzMode string slogger *slog.Logger } -func NewOrganizationHandler(orgService *service.OrganizationService, identity *service.IdentityService, slogger *slog.Logger) *OrganizationHandler { +func NewOrganizationHandler(orgService *service.OrganizationService, identity *service.IdentityService, authzMode string, slogger *slog.Logger) *OrganizationHandler { return &OrganizationHandler{ orgService: orgService, identity: identity, + authzMode: authzMode, slogger: slogger, } } @@ -164,7 +166,21 @@ func (h *OrganizationHandler) GetOrganizationByID(w http.ResponseWriter, r *http func (h *OrganizationHandler) ListOrganizations(w http.ResponseWriter, r *http.Request) error { limit, offset := parsePagination(r) - orgs, total, err := h.orgService.ListOrganizations(limit, offset) + // resolveActorErr also creates the caller's user_idp_references row on first + // use, which is what makes the membership heal below FK-safe. + performedBy, err := resolveActorErr(r, h.identity, "list organizations") + if err != nil { + return err + } + + var orgs []api.Organization + var total int + if middleware.HasEffectiveScope(r, h.authzMode, "ap:organization:manage") { + orgs, total, err = h.orgService.ListOrganizations(limit, offset) + } else { + resolvedOrgUUID, _ := middleware.GetOrganizationFromRequest(r) + orgs, total, err = h.orgService.ListOrganizationsForUser(performedBy, resolvedOrgUUID, limit, offset) + } if err != nil { return apperror.Internal.Wrap(err). WithLogMessage("failed to list organizations") diff --git a/platform-api/internal/handler/organization_integration_test.go b/platform-api/internal/handler/organization_integration_test.go new file mode 100644 index 0000000000..27430403e5 --- /dev/null +++ b/platform-api/internal/handler/organization_integration_test.go @@ -0,0 +1,228 @@ +/* + * 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. + * + */ + +// Tests GET /api/v0.9/organizations: membership-filtered listing for ordinary +// callers, list-all for callers with the ap:organization:manage scope, and +// the lazy membership heal that makes a pre-existing organization (e.g. one +// seeded before the caller ever registered) visible on first list. + +package handler + +import ( + "bytes" + "database/sql" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/wso2/api-platform/platform-api/config" + "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/model" + "github.com/wso2/api-platform/platform-api/internal/repository" + "github.com/wso2/api-platform/platform-api/internal/service" + + _ "github.com/mattn/go-sqlite3" +) + +// setupOrganizationTestEnv creates a full OrganizationHandler stack backed by +// SQLite, along with the underlying DB for direct fixture setup. +func setupOrganizationHandlerTestEnv(t *testing.T) (http.Handler, *database.DB, func()) { + t.Helper() + + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "org-handler-test.db") + // Enable foreign-key enforcement for every pooled connection via the DSN, + // not just the one connection a PRAGMA Exec would happen to run on. + sqlDB, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on") + if err != nil { + t.Fatalf("open db: %v", err) + } + db := &database.DB{DB: sqlDB} + + schemaPath := filepath.Join("..", "database", "schema.sqlite.sql") + schema, err := os.ReadFile(schemaPath) + if err != nil { + t.Fatalf("read schema: %v", err) + } + if _, err := db.Exec(string(schema)); err != nil { + t.Fatalf("apply schema: %v", err) + } + + identityService := service.NewIdentityService(repository.NewUserIdentityMappingRepo(db)) + orgRepo := repository.NewOrganizationRepo(db) + orgService := service.NewOrganizationService( + orgRepo, + repository.NewProjectRepo(db), + nil, // applicationRepo — unused by RegisterOrganization/ListOrganizations + nil, // apiRepo + nil, // gatewayRepo + nil, // llmProviderRepo + nil, // llmProxyRepo + nil, // mcpProxyRepo + nil, // llmTemplateSeeder — nil-checked, best-effort + noopAudit{}, + repository.NewUserOrganizationMappingRepo(db), + identityService, + &config.Server{}, + slog.Default(), + ) + + h := NewOrganizationHandler(orgService, identityService, middleware.ValidationModeScope, slog.Default()) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + cleanup := func() { sqlDB.Close() } + return middleware.NewTestContextMiddleware(mux), db, cleanup +} + +type organizationListResponse struct { + Count int `json:"count"` + List []struct { + Id string `json:"id"` + } `json:"list"` + Pagination struct { + Total int `json:"total"` + } `json:"pagination"` +} + +func TestOrganizationHandler_ListOrganizations_MembershipFiltered(t *testing.T) { + r, db, cleanup := setupOrganizationHandlerTestEnv(t) + t.Cleanup(cleanup) + + // A pre-existing org with no membership rows (mirrors the file-based + // seeded org, or any org created before this user ever interacted with it). + orgRepo := repository.NewOrganizationRepo(db) + if err := orgRepo.CreateOrganization(&model.Organization{ID: "org-other", Handle: "other-org", Name: "Other Org", Region: "us"}); err != nil { + t.Fatalf("failed to seed org-other: %v", err) + } + + // Caller registers their own org via the handler. + registerBody, err := json.Marshal(map[string]any{ + "displayName": "My Org", + "region": "us", + }) + if err != nil { + t.Fatalf("failed to marshal register body: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v0.9/organizations", bytes.NewReader(registerBody)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Test-User", "sub-member") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("RegisterOrganization: expected 201, got %d: %s", rec.Code, rec.Body.String()) + } + + // Listing as the same non-admin caller must show only their own org, not org-other. + listReq := httptest.NewRequest(http.MethodGet, "/api/v0.9/organizations", nil) + listReq.Header.Set("X-Test-User", "sub-member") + listRec := httptest.NewRecorder() + r.ServeHTTP(listRec, listReq) + if listRec.Code != http.StatusOK { + t.Fatalf("ListOrganizations: expected 200, got %d: %s", listRec.Code, listRec.Body.String()) + } + + var body organizationListResponse + if err := json.Unmarshal(listRec.Body.Bytes(), &body); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if body.Count != 1 || body.Pagination.Total != 1 { + t.Fatalf("expected exactly 1 visible org for a non-admin member, got count=%d total=%d body=%s", + body.Count, body.Pagination.Total, listRec.Body.String()) + } + if len(body.List) != 1 || body.List[0].Id == "other-org" { + t.Fatalf("expected the caller's own org, not org-other: %+v", body.List) + } +} + +func TestOrganizationHandler_ListOrganizations_ManageScopeSeesAll(t *testing.T) { + r, db, cleanup := setupOrganizationHandlerTestEnv(t) + t.Cleanup(cleanup) + + orgRepo := repository.NewOrganizationRepo(db) + for _, id := range []string{"org-a", "org-b", "org-c"} { + if err := orgRepo.CreateOrganization(&model.Organization{ID: id, Handle: id, Name: id, Region: "us"}); err != nil { + t.Fatalf("failed to seed %s: %v", id, err) + } + } + + listReq := httptest.NewRequest(http.MethodGet, "/api/v0.9/organizations", nil) + listReq.Header.Set("X-Test-User", "sub-admin") + listReq.Header.Set("X-Test-Scope", "ap:organization:manage") + listRec := httptest.NewRecorder() + r.ServeHTTP(listRec, listReq) + if listRec.Code != http.StatusOK { + t.Fatalf("ListOrganizations: expected 200, got %d: %s", listRec.Code, listRec.Body.String()) + } + + var body organizationListResponse + if err := json.Unmarshal(listRec.Body.Bytes(), &body); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if body.Count != 3 || body.Pagination.Total != 3 { + t.Fatalf("expected all 3 orgs visible to an ap:organization:manage caller, got count=%d total=%d body=%s", + body.Count, body.Pagination.Total, listRec.Body.String()) + } +} + +func TestOrganizationHandler_ListOrganizations_HealsMembershipForResolvedOrg(t *testing.T) { + r, db, cleanup := setupOrganizationHandlerTestEnv(t) + t.Cleanup(cleanup) + + orgRepo := repository.NewOrganizationRepo(db) + if err := orgRepo.CreateOrganization(&model.Organization{ID: "org-seeded", Handle: "seeded-org", Name: "Seeded Org", Region: "us"}); err != nil { + t.Fatalf("failed to seed org-seeded: %v", err) + } + + // Simulate OrganizationResolverMiddleware having resolved the caller's + // token org claim to this pre-existing org's platform UUID. + listReq := httptest.NewRequest(http.MethodGet, "/api/v0.9/organizations", nil) + listReq.Header.Set("X-Test-User", "sub-seeded-caller") + listReq.Header.Set("X-Test-Org", "org-seeded") + listRec := httptest.NewRecorder() + r.ServeHTTP(listRec, listReq) + if listRec.Code != http.StatusOK { + t.Fatalf("ListOrganizations: expected 200, got %d: %s", listRec.Code, listRec.Body.String()) + } + + var body organizationListResponse + if err := json.Unmarshal(listRec.Body.Bytes(), &body); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if body.Count != 1 || len(body.List) != 1 || body.List[0].Id != "seeded-org" { + t.Fatalf("expected the resolved org to be healed into view, got %+v", body) + } + + identityService := service.NewIdentityService(repository.NewUserIdentityMappingRepo(db)) + userUUID, err := identityService.ToInternalUUID("sub-seeded-caller") + if err != nil { + t.Fatalf("ToInternalUUID failed: %v", err) + } + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM user_organization_mappings WHERE user_uuid = ? AND org_uuid = ?`, + userUUID, "org-seeded").Scan(&count); err != nil { + t.Fatalf("failed to query membership: %v", err) + } + if count != 1 { + t.Fatalf("expected the heal to have created a membership row, got count=%d", count) + } +} diff --git a/platform-api/internal/handler/secret.go b/platform-api/internal/handler/secret.go index a7b2574e18..47249a2fcf 100644 --- a/platform-api/internal/handler/secret.go +++ b/platform-api/internal/handler/secret.go @@ -102,7 +102,12 @@ func (h *SecretHandler) ListSecrets(w http.ResponseWriter, r *http.Request) erro if err != nil { return apperror.ValidationFailed.Wrap(err, "updatedAfter must be an RFC3339 timestamp") } - updatedAfter = &t + // Normalize to UTC so the comparison against UTC-stored updated_at + // values is a correct chronological comparison regardless of the + // offset the client sent (some drivers compare timestamp bind + // parameters as text, where mixed offsets sort incorrectly). + utc := t.UTC() + updatedAfter = &utc } resp, err := h.secretService.List(orgID, limit, offset, updatedAfter) diff --git a/platform-api/internal/integration/audit_identity_it_test.go b/platform-api/internal/integration/audit_identity_it_test.go new file mode 100644 index 0000000000..9544a376c8 --- /dev/null +++ b/platform-api/internal/integration/audit_identity_it_test.go @@ -0,0 +1,567 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +//go:build integration + +// Cross-database coverage for the audit-fields/identity-mapping follow-up to +// #2371/#2370: user_idp_references get-or-create/reverse-lookup semantics, +// user_organization_mappings membership + cascade-on-delete parity across +// sqlite/postgres/sqlserver, and updated_by persistence on the four create +// paths that previously left it NULL (rest_apis, llm_providers, llm_proxies, +// api_keys). Run via `make it` (sqlite), `make it-postgres`, `make it-sqlserver`. + +package integration + +import ( + "testing" + + "github.com/wso2/api-platform/platform-api/internal/model" + "github.com/wso2/api-platform/platform-api/internal/repository" +) + +// TestIdentity_GetOrCreateUUID_IsIdempotentAndReversible verifies the core +// sub<->UUID mapping contract: repeated resolution of the same identity +// returns the same UUID, and the reverse lookup finds it. +func TestIdentity_GetOrCreateUUID_IsIdempotentAndReversible(t *testing.T) { + it := openITDB(t) + defer it.db.Close() + + repo := repository.NewUserIdentityMappingRepo(it.db) + + uuid1, err := repo.GetOrCreateUUID("it-sub-idempotent") + if err != nil { + t.Fatalf("[%s] GetOrCreateUUID failed: %v", it.driver, err) + } + uuid2, err := repo.GetOrCreateUUID("it-sub-idempotent") + if err != nil { + t.Fatalf("[%s] GetOrCreateUUID (second call) failed: %v", it.driver, err) + } + if uuid1 != uuid2 { + t.Fatalf("[%s] expected the same UUID on repeated resolution, got %q then %q", it.driver, uuid1, uuid2) + } + + sub, found, err := repo.GetSubByUUID(uuid1) + if err != nil { + t.Fatalf("[%s] GetSubByUUID failed: %v", it.driver, err) + } + if !found || sub != "it-sub-idempotent" { + t.Fatalf("[%s] expected reverse lookup to find it-sub-idempotent, got found=%v sub=%q", it.driver, found, sub) + } +} + +// TestIdentity_GetSubByUUID_NotFoundAfterDelete mimics forcefully removing a +// user: after the user_idp_references row is deleted, the reverse lookup must +// report not-found (the contract behind constants.DeletedUser at the service +// layer) rather than erroring. +func TestIdentity_GetSubByUUID_NotFoundAfterDelete(t *testing.T) { + it := openITDB(t) + defer it.db.Close() + + repo := repository.NewUserIdentityMappingRepo(it.db) + uuid, err := repo.GetOrCreateUUID("it-sub-to-delete") + if err != nil { + t.Fatalf("[%s] GetOrCreateUUID failed: %v", it.driver, err) + } + + it.exec(t, `DELETE FROM user_idp_references WHERE idp_id = ?`, "it-sub-to-delete") + + _, found, err := repo.GetSubByUUID(uuid) + if err != nil { + t.Fatalf("[%s] GetSubByUUID after delete failed: %v", it.driver, err) + } + if found { + t.Fatalf("[%s] expected found=false after the mapping row is deleted", it.driver) + } +} + +// TestMembership_AddListCountAndOrgDeleteCascade exercises +// user_organization_mappings end to end: add, list, count, and — the point of +// running this across every engine — that deleting the parent organization +// removes the membership row via ON DELETE CASCADE (or the repo's +// defense-in-depth manual delete on engines where that isn't guaranteed). +func TestMembership_AddListCountAndOrgDeleteCascade(t *testing.T) { + it := openITDB(t) + defer it.db.Close() + + identityRepo := repository.NewUserIdentityMappingRepo(it.db) + orgRepo := repository.NewOrganizationRepo(it.db) + membershipRepo := repository.NewUserOrganizationMappingRepo(it.db) + + userUUID, err := identityRepo.GetOrCreateUUID("it-sub-member") + if err != nil { + t.Fatalf("[%s] GetOrCreateUUID failed: %v", it.driver, err) + } + + orgA := &model.Organization{ID: id(), Handle: "it-org-a-" + id()[:8], Name: "IT Org A", Region: "us"} + orgB := &model.Organization{ID: id(), Handle: "it-org-b-" + id()[:8], Name: "IT Org B", Region: "us"} + if err := orgRepo.CreateOrganization(orgA); err != nil { + t.Fatalf("[%s] create orgA failed: %v", it.driver, err) + } + if err := orgRepo.CreateOrganization(orgB); err != nil { + t.Fatalf("[%s] create orgB failed: %v", it.driver, err) + } + + if err := membershipRepo.AddMembership(userUUID, orgA.ID); err != nil { + t.Fatalf("[%s] AddMembership(orgA) failed: %v", it.driver, err) + } + if err := membershipRepo.AddMembership(userUUID, orgB.ID); err != nil { + t.Fatalf("[%s] AddMembership(orgB) failed: %v", it.driver, err) + } + // Idempotent re-add must not error or duplicate. + if err := membershipRepo.AddMembership(userUUID, orgA.ID); err != nil { + t.Fatalf("[%s] AddMembership(orgA) duplicate should be a no-op, got: %v", it.driver, err) + } + + total, err := orgRepo.CountOrganizationsForUser(userUUID) + if err != nil { + t.Fatalf("[%s] CountOrganizationsForUser failed: %v", it.driver, err) + } + if total != 2 { + t.Fatalf("[%s] expected 2 memberships, got %d", it.driver, total) + } + + orgs, err := orgRepo.ListOrganizationsForUser(userUUID, 20, 0) + if err != nil { + t.Fatalf("[%s] ListOrganizationsForUser failed: %v", it.driver, err) + } + if len(orgs) != 2 { + t.Fatalf("[%s] expected 2 orgs listed, got %d", it.driver, len(orgs)) + } + + // Deleting orgA must remove its membership row (cascade or manual delete), + // leaving orgB's membership untouched. + if err := orgRepo.DeleteOrganization(orgA.ID); err != nil { + t.Fatalf("[%s] DeleteOrganization(orgA) failed: %v", it.driver, err) + } + + remaining, err := orgRepo.CountOrganizationsForUser(userUUID) + if err != nil { + t.Fatalf("[%s] CountOrganizationsForUser after delete failed: %v", it.driver, err) + } + if remaining != 1 { + t.Fatalf("[%s] expected 1 membership remaining after orgA delete, got %d", it.driver, remaining) + } + + remainingOrgs, err := orgRepo.ListOrganizationsForUser(userUUID, 20, 0) + if err != nil { + t.Fatalf("[%s] ListOrganizationsForUser after delete failed: %v", it.driver, err) + } + if len(remainingOrgs) != 1 || remainingOrgs[0].ID != orgB.ID { + t.Fatalf("[%s] expected only orgB to remain, got %+v", it.driver, remainingOrgs) + } +} + +// TestAuditColumns_UpdatedBySetOnCreate_RestAPI guards the previously-missing +// updated_by column on rest_apis creation across every engine's INSERT +// placeholder/rebind path. +func TestAuditColumns_UpdatedBySetOnCreate_RestAPI(t *testing.T) { + it := openITDB(t) + defer it.db.Close() + orgID, projID := seedOrgProject(t, it, "audit-api") + + apiRepo := repository.NewAPIRepo(it.db) + api := &model.API{ + Handle: "audit-api-" + id()[:8], + Name: "Audit API", + Version: "v1.0", + CreatedBy: "it-creator", + UpdatedBy: "it-creator", + ProjectID: projID, + OrganizationID: orgID, + LifeCycleStatus: "CREATED", + Configuration: model.RestAPIConfig{Name: "Audit API", Version: "v1.0"}, + } + if err := apiRepo.CreateAPI(api); err != nil { + t.Fatalf("[%s] CreateAPI failed: %v", it.driver, err) + } + + got, err := apiRepo.GetAPIByUUID(api.ID, orgID) + if err != nil { + t.Fatalf("[%s] GetAPIByUUID failed: %v", it.driver, err) + } + if got.UpdatedBy != "it-creator" { + t.Fatalf("[%s] expected updated_by=it-creator on creation, got %q", it.driver, got.UpdatedBy) + } +} + +// TestAuditColumns_UpdatedBySetOnCreate_LLMProviderAndProxy guards the +// previously-missing updated_by column (and the previously-missing SELECT of +// it) on llm_providers/llm_proxies creation and read-back. +func TestAuditColumns_UpdatedBySetOnCreate_LLMProviderAndProxy(t *testing.T) { + it := openITDB(t) + defer it.db.Close() + orgID, projID := seedOrgProject(t, it, "audit-llm") + + templateRepo := repository.NewLLMProviderTemplateRepo(it.db) + template := &model.LLMProviderTemplate{ + OrganizationUUID: orgID, + ID: "audit-template-" + id()[:8], + GroupID: "audit-template-" + id()[:8], + Name: "Audit Template", + ManagedBy: "wso2", + Version: "v1.0", + } + if err := templateRepo.Create(template); err != nil { + t.Fatalf("[%s] template create failed: %v", it.driver, err) + } + + providerRepo := repository.NewLLMProviderRepo(it.db) + provider := &model.LLMProvider{ + OrganizationUUID: orgID, + ID: "audit-provider-" + id()[:8], + Name: "Audit Provider", + Version: "v1.0", + TemplateUUID: template.UUID, + CreatedBy: "it-creator", + UpdatedBy: "it-creator", + } + if err := providerRepo.Create(provider); err != nil { + t.Fatalf("[%s] provider create failed: %v", it.driver, err) + } + + gotProvider, err := providerRepo.GetByID(provider.ID, orgID) + if err != nil { + t.Fatalf("[%s] provider GetByID failed: %v", it.driver, err) + } + if gotProvider.UpdatedBy != "it-creator" { + t.Fatalf("[%s] expected provider updated_by=it-creator, got %q", it.driver, gotProvider.UpdatedBy) + } + + proxyRepo := repository.NewLLMProxyRepo(it.db) + proxy := &model.LLMProxy{ + OrganizationUUID: orgID, + ProjectUUID: projID, + ID: "audit-proxy-" + id()[:8], + Name: "Audit Proxy", + Version: "v1.0", + ProviderUUID: provider.UUID, + CreatedBy: "it-creator", + UpdatedBy: "it-creator", + } + if err := proxyRepo.Create(proxy); err != nil { + t.Fatalf("[%s] proxy create failed: %v", it.driver, err) + } + + gotProxy, err := proxyRepo.GetByID(proxy.ID, orgID) + if err != nil { + t.Fatalf("[%s] proxy GetByID failed: %v", it.driver, err) + } + if gotProxy.UpdatedBy != "it-creator" { + t.Fatalf("[%s] expected proxy updated_by=it-creator, got %q", it.driver, gotProxy.UpdatedBy) + } +} + +// TestAuditColumns_APIKeyCreateUpdateRevoke_SetUpdatedBy guards the +// previously-missing updated_by on api_keys creation, and confirms Update and +// Revoke each set updated_by (to a different actor) without touching +// created_by, across every engine's placeholder/rebind path. +func TestAuditColumns_APIKeyCreateUpdateRevoke_SetUpdatedBy(t *testing.T) { + it := openITDB(t) + defer it.db.Close() + orgID, projID := seedOrgProject(t, it, "audit-apikey") + + apiRepo := repository.NewAPIRepo(it.db) + api := &model.API{ + Handle: "audit-apikey-api-" + id()[:8], + Name: "Audit APIKey API", + Version: "v1.0", + CreatedBy: "it-creator", + UpdatedBy: "it-creator", + ProjectID: projID, + OrganizationID: orgID, + LifeCycleStatus: "CREATED", + Configuration: model.RestAPIConfig{Name: "Audit APIKey API", Version: "v1.0"}, + } + if err := apiRepo.CreateAPI(api); err != nil { + t.Fatalf("[%s] CreateAPI failed: %v", it.driver, err) + } + + keyRepo := repository.NewAPIKeyRepo(it.db, nil) + key := &model.APIKey{ + UUID: id(), + ArtifactUUID: api.ID, + Name: "it-key", + DisplayName: "IT Key", + MaskedAPIKey: "ab12", + APIKeyHashes: `{"sha256":"` + id() + `"}`, + Status: "active", + CreatedBy: "it-creator", + UpdatedBy: "it-creator", + AllowedTargets: "ALL", + } + if err := keyRepo.Create(key); err != nil { + t.Fatalf("[%s] APIKey Create failed: %v", it.driver, err) + } + + afterCreate, err := keyRepo.GetByArtifactAndName(api.ID, "it-key") + if err != nil { + t.Fatalf("[%s] GetByArtifactAndName after create failed: %v", it.driver, err) + } + if afterCreate.UpdatedBy != "it-creator" { + t.Fatalf("[%s] expected updated_by=it-creator on creation, got %q", it.driver, afterCreate.UpdatedBy) + } + + afterCreate.MaskedAPIKey = "cd34" + afterCreate.UpdatedBy = "it-updater" + if err := keyRepo.Update(afterCreate); err != nil { + t.Fatalf("[%s] APIKey Update failed: %v", it.driver, err) + } + + afterUpdate, err := keyRepo.GetByArtifactAndName(api.ID, "it-key") + if err != nil { + t.Fatalf("[%s] GetByArtifactAndName after update failed: %v", it.driver, err) + } + if afterUpdate.UpdatedBy != "it-updater" { + t.Fatalf("[%s] expected updated_by=it-updater after update, got %q", it.driver, afterUpdate.UpdatedBy) + } + if afterUpdate.CreatedBy != "it-creator" { + t.Fatalf("[%s] Update must not touch created_by, got %q", it.driver, afterUpdate.CreatedBy) + } + + if err := keyRepo.Revoke(api.ID, "it-key", "it-revoker"); err != nil { + t.Fatalf("[%s] APIKey Revoke failed: %v", it.driver, err) + } + + afterRevoke, err := keyRepo.GetByArtifactAndName(api.ID, "it-key") + if err != nil { + t.Fatalf("[%s] GetByArtifactAndName after revoke failed: %v", it.driver, err) + } + if afterRevoke.Status != "revoked" { + t.Fatalf("[%s] expected status=revoked, got %q", it.driver, afterRevoke.Status) + } + if afterRevoke.UpdatedBy != "it-revoker" { + t.Fatalf("[%s] expected updated_by=it-revoker after revoke, got %q", it.driver, afterRevoke.UpdatedBy) + } + if afterRevoke.CreatedBy != "it-creator" { + t.Fatalf("[%s] Revoke must not touch created_by, got %q", it.driver, afterRevoke.CreatedBy) + } +} + +// seedArtifactAndGateway inserts an artifacts row (the FK target for +// artifact_gateway_mappings / deployment_status) and a gateway, returning both +// UUIDs. Used by the association/deployment-status audit tests below. +func seedArtifactAndGateway(t *testing.T, it *itDB, orgID, prefix string) (artifactID, gatewayID string) { + t.Helper() + artifactID = "art-" + id()[:8] + if _, err := it.db.Exec(it.db.Rebind( + `INSERT INTO artifacts (uuid, type, organization_uuid) VALUES (?, ?, ?)`), + artifactID, "RestApi", orgID); err != nil { + t.Fatalf("[%s] insert artifact failed: %v", it.driver, err) + } + gwRepo := repository.NewGatewayRepo(it.db) + gw := &model.Gateway{ + ID: id(), + OrganizationID: orgID, + Name: prefix + " gw", + Handle: prefix + "-gw-" + id()[:6], + Endpoints: []string{"https://localhost:8443"}, + FunctionalityType: "REGULAR", + Version: "1.0.0", + CreatedBy: "it-user", + } + if err := gwRepo.Create(gw); err != nil { + t.Fatalf("[%s] gateway Create failed: %v", it.driver, err) + } + return artifactID, gw.ID +} + +// TestAuditColumns_ArtifactGatewayMapping_SetCreatedUpdatedBy guards the +// previously-NULL created_by/updated_by on artifact_gateway_mappings: create +// seeds both from the acting user, and update bumps updated_by while leaving +// created_by. Runs against every engine. +func TestAuditColumns_ArtifactGatewayMapping_SetCreatedUpdatedBy(t *testing.T) { + it := openITDB(t) + defer it.db.Close() + orgID, _ := seedOrgProject(t, it, "audit-agm") + artifactID, gatewayID := seedArtifactAndGateway(t, it, orgID, "agm") + + apiRepo := repository.NewAPIRepo(it.db) + if err := apiRepo.CreateAPIAssociation(&model.APIAssociation{ + ArtifactID: artifactID, + OrganizationID: orgID, + GatewayID: gatewayID, + CreatedBy: "it-creator", + }); err != nil { + t.Fatalf("[%s] CreateAPIAssociation failed: %v", it.driver, err) + } + + assocs, err := apiRepo.GetAPIAssociations(artifactID, "gateway", orgID) + if err != nil { + t.Fatalf("[%s] GetAPIAssociations failed: %v", it.driver, err) + } + if len(assocs) != 1 { + t.Fatalf("[%s] expected 1 association, got %d", it.driver, len(assocs)) + } + if assocs[0].CreatedBy != "it-creator" || assocs[0].UpdatedBy != "it-creator" { + t.Fatalf("[%s] expected created_by=updated_by=it-creator on create, got created_by=%q updated_by=%q", + it.driver, assocs[0].CreatedBy, assocs[0].UpdatedBy) + } + + if err := apiRepo.UpdateAPIAssociation(artifactID, gatewayID, "gateway", orgID, "it-updater"); err != nil { + t.Fatalf("[%s] UpdateAPIAssociation failed: %v", it.driver, err) + } + assocs, err = apiRepo.GetAPIAssociations(artifactID, "gateway", orgID) + if err != nil { + t.Fatalf("[%s] GetAPIAssociations (after update) failed: %v", it.driver, err) + } + if assocs[0].CreatedBy != "it-creator" || assocs[0].UpdatedBy != "it-updater" { + t.Fatalf("[%s] expected created_by=it-creator updated_by=it-updater after update, got created_by=%q updated_by=%q", + it.driver, assocs[0].CreatedBy, assocs[0].UpdatedBy) + } +} + +// TestAuditColumns_DeploymentStatus_SetPerformedBy guards the previously-NULL +// performed_by on deployment_status, seeded from the deploying actor +// (deployment.CreatedBy) at deploy time, across every engine. +func TestAuditColumns_DeploymentStatus_SetPerformedBy(t *testing.T) { + it := openITDB(t) + defer it.db.Close() + orgID, _ := seedOrgProject(t, it, "audit-ds") + artifactID, gatewayID := seedArtifactAndGateway(t, it, orgID, "ds") + + depRepo := repository.NewDeploymentRepo(it.db, repository.NewArtifactTableRegistry()) + status := model.DeploymentStatusDeployed + dep := &model.Deployment{ + Name: "dep-" + id()[:8], + ArtifactID: artifactID, + OrganizationID: orgID, + GatewayID: gatewayID, + Content: []byte("{}"), + Status: &status, + CreatedBy: "it-deployer", + } + if err := depRepo.CreateWithLimitEnforcement(dep, 10); err != nil { + t.Fatalf("[%s] CreateWithLimitEnforcement failed: %v", it.driver, err) + } + + var performedBy string + if err := it.db.QueryRow(it.db.Rebind( + `SELECT performed_by FROM deployment_status WHERE artifact_uuid = ? AND organization_uuid = ? AND gateway_uuid = ?`), + artifactID, orgID, gatewayID).Scan(&performedBy); err != nil { + t.Fatalf("[%s] query performed_by failed: %v", it.driver, err) + } + if performedBy != "it-deployer" { + t.Fatalf("[%s] expected performed_by=it-deployer, got %q", it.driver, performedBy) + } +} + +// TestAuditColumns_SubscriptionPlanCreateUpdate_SetCreatedUpdatedBy guards +// subscription_plans.created_by/updated_by: populated on create (updated_by +// mirrors created_by), and Update sets updated_by to a new actor while +// leaving created_by untouched — the same convention already guarded for +// rest_apis/llm_providers/llm_proxies/api_keys, closing a coverage gap this +// table never had (existing tests either raw-SQL-seed it for cascade checks, +// or construct a bare model for unrelated list/hydrate assertions, so +// neither exercises the audit columns). +func TestAuditColumns_SubscriptionPlanCreateUpdate_SetCreatedUpdatedBy(t *testing.T) { + it := openITDB(t) + defer it.db.Close() + orgID, _ := seedOrgProject(t, it, "audit-plan") + + planRepo := repository.NewSubscriptionPlanRepo(it.db) + plan := &model.SubscriptionPlan{ + Handle: "audit-plan-" + id()[:8], + Name: "Audit Plan", + OrganizationUUID: orgID, + Status: model.SubscriptionPlanStatusActive, + CreatedBy: "it-creator", + UpdatedBy: "it-creator", + } + if err := planRepo.Create(plan); err != nil { + t.Fatalf("[%s] SubscriptionPlan Create failed: %v", it.driver, err) + } + + got, err := planRepo.GetByHandleAndOrg(plan.Handle, orgID) + if err != nil { + t.Fatalf("[%s] GetByHandleAndOrg failed: %v", it.driver, err) + } + if got.CreatedBy != "it-creator" || got.UpdatedBy != "it-creator" { + t.Fatalf("[%s] expected created_by=updated_by=it-creator on creation, got created_by=%q updated_by=%q", + it.driver, got.CreatedBy, got.UpdatedBy) + } + + got.UpdatedBy = "it-updater" + if err := planRepo.Update(got); err != nil { + t.Fatalf("[%s] SubscriptionPlan Update failed: %v", it.driver, err) + } + + after, err := planRepo.GetByHandleAndOrg(plan.Handle, orgID) + if err != nil { + t.Fatalf("[%s] GetByHandleAndOrg (after update) failed: %v", it.driver, err) + } + if after.UpdatedBy != "it-updater" { + t.Fatalf("[%s] expected updated_by=it-updater after update, got %q", it.driver, after.UpdatedBy) + } + if after.CreatedBy != "it-creator" { + t.Fatalf("[%s] Update must not touch created_by, got %q", it.driver, after.CreatedBy) + } +} + +// TestAuditColumns_SubscriptionCreateUpdate_SetCreatedUpdatedBy guards +// subscriptions.created_by/updated_by with the same create+update contract. +// Closes the same kind of gap as the subscription_plans test above: the only +// existing coverage of this table is cascade_test.go's raw-SQL seed helper, +// which deliberately omits every audit column since it exists purely to +// exercise FK cascade-delete behavior. +func TestAuditColumns_SubscriptionCreateUpdate_SetCreatedUpdatedBy(t *testing.T) { + it := openITDB(t) + defer it.db.Close() + orgID, _ := seedOrgProject(t, it, "audit-sub") + + artifactUUID := id() + it.exec(t, `INSERT INTO artifacts (uuid, type, organization_uuid) VALUES (?, ?, ?)`, + artifactUUID, "rest_api", orgID) + + subRepo := repository.NewSubscriptionRepo(it.db) + sub := &model.Subscription{ + ArtifactUUID: artifactUUID, + SubscriberID: "subscriber-" + id()[:8], + OrganizationUUID: orgID, + Status: model.SubscriptionStatusActive, + CreatedBy: "it-creator", + UpdatedBy: "it-creator", + } + if err := subRepo.Create(sub); err != nil { + t.Fatalf("[%s] Subscription Create failed: %v", it.driver, err) + } + + got, err := subRepo.GetByID(sub.UUID, orgID) + if err != nil { + t.Fatalf("[%s] GetByID failed: %v", it.driver, err) + } + if got.CreatedBy != "it-creator" || got.UpdatedBy != "it-creator" { + t.Fatalf("[%s] expected created_by=updated_by=it-creator on creation, got created_by=%q updated_by=%q", + it.driver, got.CreatedBy, got.UpdatedBy) + } + + got.UpdatedBy = "it-updater" + if err := subRepo.Update(got); err != nil { + t.Fatalf("[%s] Subscription Update failed: %v", it.driver, err) + } + + after, err := subRepo.GetByID(sub.UUID, orgID) + if err != nil { + t.Fatalf("[%s] GetByID (after update) failed: %v", it.driver, err) + } + if after.UpdatedBy != "it-updater" { + t.Fatalf("[%s] expected updated_by=it-updater after update, got %q", it.driver, after.UpdatedBy) + } + if after.CreatedBy != "it-creator" { + t.Fatalf("[%s] Update must not touch created_by, got %q", it.driver, after.CreatedBy) + } +} diff --git a/platform-api/internal/integration/crud_test.go b/platform-api/internal/integration/crud_test.go index 124d883839..6026a40df7 100644 --- a/platform-api/internal/integration/crud_test.go +++ b/platform-api/internal/integration/crud_test.go @@ -310,6 +310,7 @@ func TestLifecycle_APIKeyCreateListRevoke(t *testing.T) { APIKeyHashes: `{"sha256":"` + id() + `"}`, Status: "active", CreatedBy: "it-user", + UpdatedBy: "it-user", AllowedTargets: "ALL", } if err := keyRepo.Create(key); err != nil { @@ -353,7 +354,7 @@ func TestLifecycle_APIKeyCreateListRevoke(t *testing.T) { } // --- Revoke the key --- - if err := keyRepo.Revoke(artifactUUID, "key-0"); err != nil { + if err := keyRepo.Revoke(artifactUUID, "key-0", "it-revoker"); err != nil { t.Fatalf("[%s] APIKey Revoke failed: %v", it.driver, err) } revoked, err := keyRepo.GetByArtifactAndName(artifactUUID, "key-0") @@ -363,4 +364,10 @@ func TestLifecycle_APIKeyCreateListRevoke(t *testing.T) { if revoked.Status != "revoked" { t.Fatalf("[%s] APIKey Revoke did not persist status: %q", it.driver, revoked.Status) } + if revoked.UpdatedBy != "it-revoker" { + t.Fatalf("[%s] APIKey Revoke did not persist updated_by actor: %q", it.driver, revoked.UpdatedBy) + } + if revoked.CreatedBy != "it-user" { + t.Fatalf("[%s] APIKey Revoke must not touch created_by: %q", it.driver, revoked.CreatedBy) + } } diff --git a/platform-api/internal/middleware/auth.go b/platform-api/internal/middleware/auth.go index 7ea077ee31..3065504e5a 100644 --- a/platform-api/internal/middleware/auth.go +++ b/platform-api/internal/middleware/auth.go @@ -568,9 +568,10 @@ func RequireOrganization(organizationParam string) func(http.Handler) http.Handl } // NewTestContextMiddleware creates an http.Handler middleware for integration tests. -// It reads X-Test-Org and X-Test-User request headers and injects the values into -// the request context so that GetOrganizationFromRequest / GetUsernameFromRequest work -// without a real JWT. Never use this in production code. +// It reads X-Test-Org, X-Test-User, and X-Test-Scope request headers and injects the +// values into the request context so that GetOrganizationFromRequest / +// GetUsernameFromRequest / GetScopeFromRequest work without a real JWT. Never use this +// in production code. func NewTestContextMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -581,6 +582,9 @@ func NewTestContextMiddleware(next http.Handler) http.Handler { ctx = context.WithValue(ctx, keyUsername, user) ctx = context.WithValue(ctx, keyUserID, user) } + if scope := r.Header.Get("X-Test-Scope"); scope != "" { + ctx = context.WithValue(ctx, keyScope, scope) + } next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/platform-api/internal/middleware/authorization.go b/platform-api/internal/middleware/authorization.go index 4e0e295e58..81348c4e40 100644 --- a/platform-api/internal/middleware/authorization.go +++ b/platform-api/internal/middleware/authorization.go @@ -131,3 +131,16 @@ func resolveEffectiveScopes(r *http.Request, mode string) []string { scope, _ := GetScopeFromRequest(r) return strings.Fields(scope) } + +// HasEffectiveScope reports whether the request's effective scopes (the scope +// claim in "scope" mode, or IDP roles expanded via the role-scope map in +// "role" mode) satisfy the given scope, honoring ":*" wildcards the same way +// ScopeEnforcer does. +func HasEffectiveScope(r *http.Request, mode, scope string) bool { + for _, have := range resolveEffectiveScopes(r, mode) { + if scopeSatisfies(have, scope) { + return true + } + } + return false +} diff --git a/platform-api/internal/model/api.go b/platform-api/internal/model/api.go index 6544623593..64d473962f 100644 --- a/platform-api/internal/model/api.go +++ b/platform-api/internal/model/api.go @@ -109,6 +109,8 @@ type APIAssociation struct { ArtifactID string `json:"artifactId" db:"artifact_uuid"` OrganizationID string `json:"organizationId" db:"organization_uuid"` GatewayID string `json:"gatewayId" db:"gateway_uuid"` + 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"` } diff --git a/platform-api/internal/model/apikey.go b/platform-api/internal/model/apikey.go index 5504a6ae2b..51ad5a787f 100644 --- a/platform-api/internal/model/apikey.go +++ b/platform-api/internal/model/apikey.go @@ -31,6 +31,7 @@ type APIKey struct { CreatedAt time.Time CreatedBy string UpdatedAt time.Time + UpdatedBy string ExpiresAt *time.Time Issuer *string // Identifier of the developer portal that provisioned this key; nil if not provided AllowedTargets string // Comma-separated list of allowed gateways; defaults to 'ALL' diff --git a/platform-api/internal/model/user_organization_mapping.go b/platform-api/internal/model/user_organization_mapping.go index 82403f31f7..b34bf765e0 100644 --- a/platform-api/internal/model/user_organization_mapping.go +++ b/platform-api/internal/model/user_organization_mapping.go @@ -20,11 +20,12 @@ package model import "time" // UserOrganizationMapping records that a user (identified by their internal -// UUID) has onboarded to an organization. Populate-only today: no reader -// depends on this table yet. There is no ON DELETE CASCADE on either FK by -// design — deleting the referenced user or organization must delete the -// matching rows here first, in the same transaction (see -// repository.UserOrganizationMappingRepository). +// UUID) has onboarded to an organization. Both FKs are declared ON DELETE +// CASCADE in the schema, so deleting the referenced user or organization +// removes matching rows here automatically on engines that enforce FKs. +// repository.UserOrganizationMappingRepository still deletes matching rows +// explicitly (in the same transaction) as defense-in-depth for pooled SQLite +// connections that may not enforce foreign keys. type UserOrganizationMapping struct { UserUUID string `json:"userUuid" db:"user_uuid"` OrgUUID string `json:"orgUuid" db:"org_uuid"` diff --git a/platform-api/internal/repository/api.go b/platform-api/internal/repository/api.go index f7d8652c8e..115179d061 100644 --- a/platform-api/internal/repository/api.go +++ b/platform-api/internal/repository/api.go @@ -60,8 +60,8 @@ func (r *APIRepo) CreateAPI(api *model.API) error { return fmt.Errorf("failed to generate API ID: %w", err) } api.ID = apiID - api.CreatedAt = time.Now() - api.UpdatedAt = time.Now() + api.CreatedAt = time.Now().UTC() + api.UpdatedAt = api.CreatedAt configurationJSON, err := serializeAPIConfigurations(api.Configuration) if err != nil { @@ -86,12 +86,12 @@ func (r *APIRepo) CreateAPI(api *model.API) error { } apiQuery := ` - INSERT INTO rest_apis (uuid, organization_uuid, handle, display_name, version, description, created_by, project_uuid, lifecycle_status, configuration, origin, data_version, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO rest_apis (uuid, organization_uuid, handle, display_name, version, description, created_by, updated_by, project_uuid, lifecycle_status, configuration, origin, data_version, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` _, err = tx.Exec(r.db.Rebind(apiQuery), api.ID, api.OrganizationID, api.Handle, api.Name, api.Version, - api.Description, api.CreatedBy, api.ProjectID, api.LifeCycleStatus, + api.Description, api.CreatedBy, api.UpdatedBy, api.ProjectID, api.LifeCycleStatus, []byte(configurationJSON), origin, api.DataVersion, api.CreatedAt, api.UpdatedAt) if err != nil { return err @@ -394,7 +394,7 @@ func (r *APIRepo) UpdateAPI(api *model.API) error { } defer tx.Rollback() - api.UpdatedAt = time.Now() + api.UpdatedAt = time.Now().UTC() configurationJSON, err := serializeAPIConfigurations(api.Configuration) if err != nil { @@ -568,25 +568,35 @@ func (r *APIRepo) CheckAPIExistsByNameAndVersionInOrganization(name, version, or } // CreateAPIAssociation creates a gateway-API association in artifact_gateway_mappings. +// created_by/updated_by are seeded from association.CreatedBy (the acting user); on create +// updated_by mirrors created_by. Both are stored as NULL when the actor is unknown. func (r *APIRepo) CreateAPIAssociation(association *model.APIAssociation) error { + association.CreatedAt = time.Now().UTC() + association.UpdatedAt = association.CreatedAt + if association.UpdatedBy == "" { + association.UpdatedBy = association.CreatedBy + } + query := ` - INSERT INTO artifact_gateway_mappings (artifact_uuid, organization_uuid, gateway_uuid, created_at, updated_at) - VALUES (?, ?, ?, ?, ?) + INSERT INTO artifact_gateway_mappings (artifact_uuid, organization_uuid, gateway_uuid, created_by, updated_by, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) ` _, err := r.db.Exec(r.db.Rebind(query), association.ArtifactID, association.OrganizationID, association.GatewayID, + association.CreatedBy, association.UpdatedBy, association.CreatedAt, association.UpdatedAt) return err } -// UpdateAPIAssociation updates the updated_at timestamp for a gateway-API association. -func (r *APIRepo) UpdateAPIAssociation(apiUUID, resourceId, associationType, orgUUID string) error { +// UpdateAPIAssociation updates the updated_at timestamp and updated_by actor for a +// gateway-API association. +func (r *APIRepo) UpdateAPIAssociation(apiUUID, resourceId, associationType, orgUUID, updatedBy string) error { query := ` UPDATE artifact_gateway_mappings - SET updated_at = ? + SET updated_at = ?, updated_by = ? WHERE artifact_uuid = ? AND gateway_uuid = ? AND organization_uuid = ? ` - _, err := r.db.Exec(r.db.Rebind(query), time.Now(), apiUUID, resourceId, orgUUID) + _, err := r.db.Exec(r.db.Rebind(query), time.Now().UTC(), updatedBy, apiUUID, resourceId, orgUUID) return err } @@ -594,7 +604,7 @@ func (r *APIRepo) UpdateAPIAssociation(apiUUID, resourceId, associationType, org // associationType is accepted for interface compatibility but only 'gateway' associations are stored. func (r *APIRepo) GetAPIAssociations(apiUUID, associationType, orgUUID string) ([]*model.APIAssociation, error) { query := ` - SELECT artifact_uuid, organization_uuid, gateway_uuid, created_at, updated_at + SELECT artifact_uuid, organization_uuid, gateway_uuid, created_by, updated_by, created_at, updated_at FROM artifact_gateway_mappings WHERE artifact_uuid = ? AND organization_uuid = ? ` @@ -607,10 +617,14 @@ func (r *APIRepo) GetAPIAssociations(apiUUID, associationType, orgUUID string) ( var associations []*model.APIAssociation for rows.Next() { assoc := &model.APIAssociation{} - err := rows.Scan(&assoc.ArtifactID, &assoc.OrganizationID, &assoc.GatewayID, &assoc.CreatedAt, &assoc.UpdatedAt) + // created_by/updated_by are nullable (rows predating audit tracking store NULL). + var createdBy, updatedBy sql.NullString + err := rows.Scan(&assoc.ArtifactID, &assoc.OrganizationID, &assoc.GatewayID, &createdBy, &updatedBy, &assoc.CreatedAt, &assoc.UpdatedAt) if err != nil { return nil, err } + assoc.CreatedBy = createdBy.String + assoc.UpdatedBy = updatedBy.String associations = append(associations, assoc) } diff --git a/platform-api/internal/repository/api_test.go b/platform-api/internal/repository/api_test.go index 6af1782840..36e8f35653 100644 --- a/platform-api/internal/repository/api_test.go +++ b/platform-api/internal/repository/api_test.go @@ -21,6 +21,7 @@ import ( "database/sql" "reflect" "testing" + "time" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/database" @@ -119,6 +120,138 @@ func TestAPIRepo_CreateAndRead(t *testing.T) { } } +// TestAPIRepo_CreateAPI_SetsUpdatedBy guards against a prior gap where the +// rest_apis INSERT omitted updated_by, leaving it NULL until the first update. +func TestAPIRepo_CreateAPI_SetsUpdatedBy(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + repo := NewAPIRepo(db) + + orgUUID := "org-crud-updatedby" + projectUUID := "project-crud-updatedby" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + + api := &model.API{ + Handle: "updatedby-api", + Name: "UpdatedBy API", + Version: "1.0.0", + CreatedBy: "test-user", + UpdatedBy: "test-user", + ProjectID: projectUUID, + OrganizationID: orgUUID, + LifeCycleStatus: "CREATED", + Configuration: model.RestAPIConfig{ + Name: "UpdatedBy API", + Version: "1.0.0", + Transport: []string{"https"}, + }, + } + + if err := repo.CreateAPI(api); err != nil { + t.Fatalf("CreateAPI failed: %v", err) + } + defer func() { + if err := repo.DeleteAPI(api.ID, orgUUID); err != nil { + t.Errorf("DeleteAPI cleanup failed: %v", err) + } + }() + + created, err := repo.GetAPIByUUID(api.ID, orgUUID) + if err != nil { + t.Fatalf("GetAPIByUUID failed: %v", err) + } + if created == nil { + t.Fatal("GetAPIByUUID returned nil") + } + if created.UpdatedBy == "" { + t.Fatal("expected updated_by to be set on creation, got empty string") + } + if created.UpdatedBy != created.CreatedBy { + t.Fatalf("expected updated_by == created_by on creation, got created_by=%q updated_by=%q", created.CreatedBy, created.UpdatedBy) + } +} + +// TestAPIRepo_CreateAPIAssociation_NormalizesTimestampsToUTC guards against a +// prior gap where CreateAPIAssociation persisted whatever CreatedAt/UpdatedAt +// the caller passed in (typically local-server-time time.Now()) instead of +// normalizing to UTC, unlike every other repository Create method. +func TestAPIRepo_CreateAPIAssociation_NormalizesTimestampsToUTC(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + apiRepo := NewAPIRepo(db) + gatewayRepo := NewGatewayRepo(db) + + orgUUID := "org-assoc-utc" + projectUUID := "project-assoc-utc" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + + restAPI := &model.API{ + Handle: "assoc-utc-api", + Name: "Assoc UTC API", + Version: "1.0.0", + CreatedBy: "test-user", + UpdatedBy: "test-user", + ProjectID: projectUUID, + OrganizationID: orgUUID, + LifeCycleStatus: "CREATED", + Configuration: model.RestAPIConfig{ + Name: "Assoc UTC API", + Version: "1.0.0", + Transport: []string{"https"}, + }, + } + if err := apiRepo.CreateAPI(restAPI); err != nil { + t.Fatalf("CreateAPI failed: %v", err) + } + + gateway := &model.Gateway{ + ID: "gw-assoc-utc", + OrganizationID: orgUUID, + Handle: "gw-assoc-utc", + Name: "Assoc UTC Gateway", + } + if err := gatewayRepo.Create(gateway); err != nil { + t.Fatalf("gateway Create failed: %v", err) + } + + // Deliberately pass a non-UTC CreatedAt/UpdatedAt, mimicking a caller that + // computed time.Now() (server-local time) instead of time.Now().UTC(). + localZone := time.FixedZone("Test/Local", 5*60*60) + association := &model.APIAssociation{ + ArtifactID: restAPI.ID, + OrganizationID: orgUUID, + GatewayID: gateway.ID, + CreatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, localZone), + UpdatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, localZone), + } + if err := apiRepo.CreateAPIAssociation(association); err != nil { + t.Fatalf("CreateAPIAssociation failed: %v", err) + } + + if association.CreatedAt.Location() != time.UTC { + t.Fatalf("expected CreateAPIAssociation to normalize CreatedAt to UTC, got location %v", association.CreatedAt.Location()) + } + if !association.UpdatedAt.Equal(association.CreatedAt) { + t.Fatalf("expected UpdatedAt == CreatedAt on creation, got created=%v updated=%v", association.CreatedAt, association.UpdatedAt) + } + if time.Since(association.CreatedAt) > time.Minute { + t.Fatalf("expected CreatedAt to be normalized to roughly now, got %v", association.CreatedAt) + } + + associations, err := apiRepo.GetAPIAssociations(restAPI.ID, constants.AssociationTypeGateway, orgUUID) + if err != nil { + t.Fatalf("GetAPIAssociations failed: %v", err) + } + if len(associations) != 1 { + t.Fatalf("expected exactly 1 association, got %d", len(associations)) + } + if associations[0].CreatedAt.Year() == 2020 { + t.Fatalf("expected the persisted CreatedAt to be the normalized value, not the caller-supplied 2020 date: %v", associations[0].CreatedAt) + } +} + func TestAPIRepo_Update(t *testing.T) { db, cleanup := setupTestDB(t) t.Cleanup(cleanup) diff --git a/platform-api/internal/repository/apikey.go b/platform-api/internal/repository/apikey.go index 576420baaa..0a50220887 100644 --- a/platform-api/internal/repository/apikey.go +++ b/platform-api/internal/repository/apikey.go @@ -42,16 +42,16 @@ func NewAPIKeyRepo(db *database.DB, reg *ArtifactTableRegistry) APIKeyRepository // Create inserts a new API key record func (r *APIKeyRepo) Create(key *model.APIKey) error { - key.CreatedAt = time.Now() - key.UpdatedAt = time.Now() + key.CreatedAt = time.Now().UTC() + key.UpdatedAt = key.CreatedAt query := ` - INSERT INTO api_keys (uuid, artifact_uuid, handle, display_name, masked_api_key, api_key_hashes, status, created_at, created_by, updated_at, expires_at, issuer, allowed_targets) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO api_keys (uuid, artifact_uuid, handle, display_name, masked_api_key, api_key_hashes, status, created_at, created_by, updated_at, updated_by, expires_at, issuer, allowed_targets) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` _, err := r.db.Exec(r.db.Rebind(query), key.UUID, key.ArtifactUUID, key.Name, key.DisplayName, key.MaskedAPIKey, []byte(key.APIKeyHashes), - key.Status, key.CreatedAt, key.CreatedBy, key.UpdatedAt, key.ExpiresAt, + key.Status, key.CreatedAt, key.CreatedBy, key.UpdatedAt, key.UpdatedBy, key.ExpiresAt, key.Issuer, key.AllowedTargets, ) return err @@ -59,15 +59,15 @@ func (r *APIKeyRepo) Create(key *model.APIKey) error { // Update modifies an existing API key record identified by (artifact_uuid, name) func (r *APIKeyRepo) Update(key *model.APIKey) error { - key.UpdatedAt = time.Now() + key.UpdatedAt = time.Now().UTC() query := ` UPDATE api_keys - SET masked_api_key = ?, api_key_hashes = ?, status = ?, updated_at = ?, expires_at = ?, issuer = ? + SET masked_api_key = ?, api_key_hashes = ?, status = ?, updated_at = ?, updated_by = ?, expires_at = ?, issuer = ? WHERE artifact_uuid = ? AND handle = ? ` result, err := r.db.Exec(r.db.Rebind(query), - key.MaskedAPIKey, []byte(key.APIKeyHashes), key.Status, key.UpdatedAt, key.ExpiresAt, key.Issuer, + key.MaskedAPIKey, []byte(key.APIKeyHashes), key.Status, key.UpdatedAt, key.UpdatedBy, key.ExpiresAt, key.Issuer, key.ArtifactUUID, key.Name, ) if err != nil { @@ -83,14 +83,14 @@ func (r *APIKeyRepo) Update(key *model.APIKey) error { return nil } -// Revoke marks an API key as revoked -func (r *APIKeyRepo) Revoke(artifactUUID, name string) error { +// Revoke marks an API key as revoked, recording the revoking actor in updated_by +func (r *APIKeyRepo) Revoke(artifactUUID, name, updatedBy string) error { query := ` UPDATE api_keys - SET status = 'revoked', updated_at = ? + SET status = 'revoked', updated_at = ?, updated_by = ? WHERE artifact_uuid = ? AND handle = ? ` - result, err := r.db.Exec(r.db.Rebind(query), time.Now(), artifactUUID, name) + result, err := r.db.Exec(r.db.Rebind(query), time.Now().UTC(), updatedBy, artifactUUID, name) if err != nil { return err } @@ -107,7 +107,7 @@ func (r *APIKeyRepo) Revoke(artifactUUID, name string) error { // ListByArtifact retrieves all API keys for a given artifact UUID func (r *APIKeyRepo) ListByArtifact(artifactUUID string) ([]*model.APIKey, error) { query := ` - SELECT uuid, artifact_uuid, handle, display_name, masked_api_key, api_key_hashes, status, created_at, created_by, updated_at, expires_at, issuer, allowed_targets + SELECT uuid, artifact_uuid, handle, display_name, masked_api_key, api_key_hashes, status, created_at, created_by, updated_at, updated_by, expires_at, issuer, allowed_targets FROM api_keys WHERE artifact_uuid = ? ORDER BY created_at DESC @@ -121,16 +121,17 @@ func (r *APIKeyRepo) ListByArtifact(artifactUUID string) ([]*model.APIKey, error var keys []*model.APIKey for rows.Next() { key := &model.APIKey{} - var issuer sql.NullString + var issuer, updatedBy sql.NullString var keyHashes []byte if err := rows.Scan( &key.UUID, &key.ArtifactUUID, &key.Name, &key.DisplayName, &key.MaskedAPIKey, &keyHashes, - &key.Status, &key.CreatedAt, &key.CreatedBy, &key.UpdatedAt, &key.ExpiresAt, + &key.Status, &key.CreatedAt, &key.CreatedBy, &key.UpdatedAt, &updatedBy, &key.ExpiresAt, &issuer, &key.AllowedTargets, ); err != nil { return nil, err } key.APIKeyHashes = string(keyHashes) + key.UpdatedBy = updatedBy.String if issuer.Valid { key.Issuer = &issuer.String } @@ -147,7 +148,7 @@ func (r *APIKeyRepo) ListByArtifact(artifactUUID string) ([]*model.APIKey, error func (r *APIKeyRepo) ListByGatewayAndKind(gatewayID, orgID, kind, issuer string) ([]*model.APIKey, error) { base := ` SELECT k.uuid, k.artifact_uuid, k.handle, k.display_name, k.masked_api_key, k.api_key_hashes, - k.status, k.created_at, k.created_by, k.updated_at, k.expires_at, + k.status, k.created_at, k.created_by, k.updated_at, k.updated_by, k.expires_at, k.issuer, k.allowed_targets FROM api_keys k INNER JOIN artifacts a ON k.artifact_uuid = a.uuid @@ -173,16 +174,17 @@ func (r *APIKeyRepo) ListByGatewayAndKind(gatewayID, orgID, kind, issuer string) var keys []*model.APIKey for rows.Next() { key := &model.APIKey{} - var issuerVal sql.NullString + var issuerVal, updatedBy sql.NullString var keyHashes []byte if err := rows.Scan( &key.UUID, &key.ArtifactUUID, &key.Name, &key.DisplayName, &key.MaskedAPIKey, &keyHashes, - &key.Status, &key.CreatedAt, &key.CreatedBy, &key.UpdatedAt, &key.ExpiresAt, + &key.Status, &key.CreatedAt, &key.CreatedBy, &key.UpdatedAt, &updatedBy, &key.ExpiresAt, &issuerVal, &key.AllowedTargets, ); err != nil { return nil, err } key.APIKeyHashes = string(keyHashes) + key.UpdatedBy = updatedBy.String if issuerVal.Valid { key.Issuer = &issuerVal.String } @@ -224,7 +226,7 @@ func (r *APIKeyRepo) ListAPIKeysByUser(orgUUID, username string, kinds []string) query := fmt.Sprintf(` SELECT ak.uuid, ak.artifact_uuid, ak.handle, ak.display_name, ak.masked_api_key, ak.api_key_hashes, - ak.status, ak.created_at, ak.created_by, ak.updated_at, ak.expires_at, + ak.status, ak.created_at, ak.created_by, ak.updated_at, ak.updated_by, ak.expires_at, ak.issuer, ak.allowed_targets, src.handle, a.type FROM api_keys ak @@ -247,17 +249,18 @@ func (r *APIKeyRepo) ListAPIKeysByUser(orgUUID, username string, kinds []string) var keys []*model.UserAPIKey for rows.Next() { key := &model.UserAPIKey{} - var issuer sql.NullString + var issuer, updatedBy sql.NullString var keyHashes []byte if err := rows.Scan( &key.UUID, &key.ArtifactUUID, &key.Name, &key.DisplayName, &key.MaskedAPIKey, &keyHashes, - &key.Status, &key.CreatedAt, &key.CreatedBy, &key.UpdatedAt, &key.ExpiresAt, + &key.Status, &key.CreatedAt, &key.CreatedBy, &key.UpdatedAt, &updatedBy, &key.ExpiresAt, &issuer, &key.AllowedTargets, &key.ArtifactHandle, &key.ArtifactType, ); err != nil { return nil, err } key.APIKeyHashes = string(keyHashes) + key.UpdatedBy = updatedBy.String if issuer.Valid { key.Issuer = &issuer.String } @@ -269,16 +272,16 @@ func (r *APIKeyRepo) ListAPIKeysByUser(orgUUID, username string, kinds []string) // GetByArtifactAndName retrieves an API key by artifact UUID and name func (r *APIKeyRepo) GetByArtifactAndName(artifactUUID, name string) (*model.APIKey, error) { key := &model.APIKey{} - var issuer sql.NullString + var issuer, updatedBy sql.NullString var keyHashes []byte query := ` - SELECT uuid, artifact_uuid, handle, display_name, masked_api_key, api_key_hashes, status, created_at, created_by, updated_at, expires_at, issuer, allowed_targets + SELECT uuid, artifact_uuid, handle, display_name, masked_api_key, api_key_hashes, status, created_at, created_by, updated_at, updated_by, expires_at, issuer, allowed_targets FROM api_keys WHERE artifact_uuid = ? AND handle = ? ` err := r.db.QueryRow(r.db.Rebind(query), artifactUUID, name).Scan( &key.UUID, &key.ArtifactUUID, &key.Name, &key.DisplayName, &key.MaskedAPIKey, &keyHashes, - &key.Status, &key.CreatedAt, &key.CreatedBy, &key.UpdatedAt, &key.ExpiresAt, + &key.Status, &key.CreatedAt, &key.CreatedBy, &key.UpdatedAt, &updatedBy, &key.ExpiresAt, &issuer, &key.AllowedTargets, ) if errors.Is(err, sql.ErrNoRows) { @@ -288,6 +291,7 @@ func (r *APIKeyRepo) GetByArtifactAndName(artifactUUID, name string) (*model.API return nil, err } key.APIKeyHashes = string(keyHashes) + key.UpdatedBy = updatedBy.String if issuer.Valid { key.Issuer = &issuer.String } diff --git a/platform-api/internal/repository/apikey_test.go b/platform-api/internal/repository/apikey_test.go new file mode 100644 index 0000000000..453aa8e214 --- /dev/null +++ b/platform-api/internal/repository/apikey_test.go @@ -0,0 +1,158 @@ +/* + * 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 ( + "testing" + + "github.com/wso2/api-platform/platform-api/internal/database" + "github.com/wso2/api-platform/platform-api/internal/model" + + _ "github.com/mattn/go-sqlite3" +) + +// createTestArtifactAPI creates a minimal REST API and returns its artifact UUID, +// for tests that only need a valid artifacts row to attach an API key to. +func createTestArtifactAPI(t *testing.T, db *database.DB, orgUUID, projectUUID string) string { + t.Helper() + apiRepo := NewAPIRepo(db) + api := &model.API{ + Handle: "artifact-fixture", + Name: "Artifact Fixture", + Version: "1.0.0", + CreatedBy: "fixture-user", + UpdatedBy: "fixture-user", + ProjectID: projectUUID, + OrganizationID: orgUUID, + LifeCycleStatus: "CREATED", + Configuration: model.RestAPIConfig{ + Name: "Artifact Fixture", + Version: "1.0.0", + Transport: []string{"https"}, + }, + } + if err := apiRepo.CreateAPI(api); err != nil { + t.Fatalf("failed to create artifact fixture API: %v", err) + } + return api.ID +} + +func TestAPIKeyRepo_CreateAndRead_SetsUpdatedBy(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + orgUUID := "org-apikey-updatedby" + projectUUID := "project-apikey-updatedby" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + artifactUUID := createTestArtifactAPI(t, db, orgUUID, projectUUID) + + repo := NewAPIKeyRepo(db, nil) + key := &model.APIKey{ + UUID: "apikey-uuid-1", + ArtifactUUID: artifactUUID, + Name: "key-1", + DisplayName: "Key 1", + MaskedAPIKey: "ab12", + APIKeyHashes: `{"sha256":"hash1"}`, + Status: "active", + CreatedBy: "test-user", + UpdatedBy: "test-user", + AllowedTargets: "ALL", + } + if err := repo.Create(key); err != nil { + t.Fatalf("Create failed: %v", err) + } + + got, err := repo.GetByArtifactAndName(artifactUUID, "key-1") + if err != nil { + t.Fatalf("GetByArtifactAndName failed: %v", err) + } + if got == nil { + t.Fatal("GetByArtifactAndName returned nil") + } + if got.UpdatedBy == "" { + t.Fatal("expected updated_by to be set on creation, got empty string") + } + if got.UpdatedBy != got.CreatedBy { + t.Fatalf("expected updated_by == created_by on creation, got created_by=%q updated_by=%q", got.CreatedBy, got.UpdatedBy) + } +} + +func TestAPIKeyRepo_UpdateAndRevoke_SetUpdatedByWithoutTouchingCreatedBy(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + orgUUID := "org-apikey-update-revoke" + projectUUID := "project-apikey-update-revoke" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + artifactUUID := createTestArtifactAPI(t, db, orgUUID, projectUUID) + + repo := NewAPIKeyRepo(db, nil) + key := &model.APIKey{ + UUID: "apikey-uuid-2", + ArtifactUUID: artifactUUID, + Name: "key-2", + DisplayName: "Key 2", + MaskedAPIKey: "cd34", + APIKeyHashes: `{"sha256":"hash2"}`, + Status: "active", + CreatedBy: "creator-user", + UpdatedBy: "creator-user", + AllowedTargets: "ALL", + } + if err := repo.Create(key); err != nil { + t.Fatalf("Create failed: %v", err) + } + + // Update by a different actor: updated_by must change, created_by must not. + key.MaskedAPIKey = "ef56" + key.UpdatedBy = "updater-user" + if err := repo.Update(key); err != nil { + t.Fatalf("Update failed: %v", err) + } + + afterUpdate, err := repo.GetByArtifactAndName(artifactUUID, "key-2") + if err != nil { + t.Fatalf("GetByArtifactAndName after update failed: %v", err) + } + if afterUpdate.UpdatedBy != "updater-user" { + t.Fatalf("expected updated_by to change to updater-user, got %q", afterUpdate.UpdatedBy) + } + if afterUpdate.CreatedBy != "creator-user" { + t.Fatalf("Update must not touch created_by, got %q", afterUpdate.CreatedBy) + } + + // Revoke by yet another actor. + if err := repo.Revoke(artifactUUID, "key-2", "revoker-user"); err != nil { + t.Fatalf("Revoke failed: %v", err) + } + + afterRevoke, err := repo.GetByArtifactAndName(artifactUUID, "key-2") + if err != nil { + t.Fatalf("GetByArtifactAndName after revoke failed: %v", err) + } + if afterRevoke.Status != "revoked" { + t.Fatalf("expected status revoked, got %q", afterRevoke.Status) + } + if afterRevoke.UpdatedBy != "revoker-user" { + t.Fatalf("expected updated_by to change to revoker-user, got %q", afterRevoke.UpdatedBy) + } + if afterRevoke.CreatedBy != "creator-user" { + t.Fatalf("Revoke must not touch created_by, got %q", afterRevoke.CreatedBy) + } +} diff --git a/platform-api/internal/repository/application.go b/platform-api/internal/repository/application.go index 5ffb0642eb..d7a1915123 100644 --- a/platform-api/internal/repository/application.go +++ b/platform-api/internal/repository/application.go @@ -37,7 +37,7 @@ func NewApplicationRepo(db *database.DB, reg *ArtifactTableRegistry) Application } func (r *ApplicationRepo) CreateApplication(app *model.Application) error { - now := time.Now() + now := time.Now().UTC() app.CreatedAt = now app.UpdatedAt = now @@ -334,7 +334,7 @@ func (r *ApplicationRepo) CheckApplicationHandleExists(handle, orgID string) (bo } func (r *ApplicationRepo) UpdateApplication(app *model.Application) error { - app.UpdatedAt = time.Now() + app.UpdatedAt = time.Now().UTC() _, err := r.db.Exec(r.db.Rebind(` UPDATE applications @@ -484,7 +484,7 @@ func (r *ApplicationRepo) AddApplicationAPIKeys(applicationUUID string, apiKeyID if _, ok := existing[apiKeyID]; ok { continue } - now := time.Now() + now := time.Now().UTC() if _, err = tx.Exec(r.db.Rebind(` INSERT INTO application_api_key_mappings (application_uuid, api_key_id, created_at) VALUES (?, ?, ?) @@ -504,7 +504,7 @@ func (r *ApplicationRepo) AddApplicationAssociations(applicationUUID string, tar defer tx.Rollback() for _, targetUUID := range uniqueStrings(targetUUIDs) { - now := time.Now() + now := time.Now().UTC() if _, err = tx.Exec(r.db.Rebind(` INSERT INTO application_artifact_mappings (application_uuid, artifact_uuid, created_at) VALUES (?, ?, ?) diff --git a/platform-api/internal/repository/audit.go b/platform-api/internal/repository/audit.go index 52d4836f78..a1932f73b2 100644 --- a/platform-api/internal/repository/audit.go +++ b/platform-api/internal/repository/audit.go @@ -39,6 +39,6 @@ func NewAuditRepo(db *database.DB) AuditRepository { func (r *AuditRepo) Record(action, resourceUUID, resourceType, orgUUID, performedBy string) error { id := uuid.New().String() query := `INSERT INTO audit (uuid, action, resource_uuid, resource_type, organization_uuid, performed_by, performed_at) VALUES (?, ?, ?, ?, ?, ?, ?)` - _, err := r.db.Exec(r.db.Rebind(query), id, action, resourceUUID, resourceType, orgUUID, performedBy, time.Now()) + _, err := r.db.Exec(r.db.Rebind(query), id, action, resourceUUID, resourceType, orgUUID, performedBy, time.Now().UTC()) return err } diff --git a/platform-api/internal/repository/custom_policy.go b/platform-api/internal/repository/custom_policy.go index 303e6c73db..ecbdba9261 100644 --- a/platform-api/internal/repository/custom_policy.go +++ b/platform-api/internal/repository/custom_policy.go @@ -39,7 +39,7 @@ func NewCustomPolicyRepo(db *database.DB) CustomPolicyRepository { // InsertCustomPolicy inserts or updates a custom policy by (organization_uuid, name, version). func (r *CustomPolicyRepo) InsertCustomPolicy(policy *model.CustomPolicy) error { - now := time.Now() + now := time.Now().UTC() query := r.db.BuildUpsertQuery( "gateway_custom_policies", []string{"uuid", "organization_uuid", "name", "display_name", "version", "description", "policy_definition", "created_by", "updated_by", "created_at", "updated_at"}, @@ -113,7 +113,7 @@ func (r *CustomPolicyRepo) ListCustomPolicyByOrganization(orgUUID string) ([]*mo // UpdateCustomPolicy updates an existing policy's version and definition identified by (org, name, oldVersion). func (r *CustomPolicyRepo) UpdateCustomPolicy(policy *model.CustomPolicy, oldVersion string) error { - now := time.Now() + now := time.Now().UTC() query := ` UPDATE gateway_custom_policies SET version = ?, display_name = ?, description = ?, policy_definition = ?, updated_by = ?, updated_at = ? diff --git a/platform-api/internal/repository/deployment.go b/platform-api/internal/repository/deployment.go index cf5d788a12..67c36e46e8 100644 --- a/platform-api/internal/repository/deployment.go +++ b/platform-api/internal/repository/deployment.go @@ -67,7 +67,7 @@ func (r *DeploymentRepo) CreateWithLimitEnforcement(deployment *model.Deployment // Preserve a caller-provided created_at (the DP->CP import flow sets it to the gateway's // deployment time, which drives the last-in-wins watermark); default to now otherwise. if deployment.CreatedAt.IsZero() { - deployment.CreatedAt = time.Now() + deployment.CreatedAt = time.Now().UTC() } // Status must be provided and should be DEPLOYED for new deployments @@ -76,7 +76,7 @@ func (r *DeploymentRepo) CreateWithLimitEnforcement(deployment *model.Deployment deployment.Status = &deployed } - updatedAt := time.Now() + updatedAt := time.Now().UTC() deployment.UpdatedAt = &updatedAt // 1. Count total deployments for this artifact+Gateway @@ -167,9 +167,9 @@ func (r *DeploymentRepo) CreateWithLimitEnforcement(deployment *model.Deployment // 4. Insert or update deployment status (UPSERT) statusQuery := r.db.BuildUpsertQuery( "deployment_status", - []string{"artifact_uuid", "organization_uuid", "gateway_uuid", "deployment_uuid", "status", "status_desired", "performed_at", "status_reason", "updated_at"}, + []string{"artifact_uuid", "organization_uuid", "gateway_uuid", "deployment_uuid", "status", "status_desired", "performed_at", "performed_by", "status_reason", "updated_at"}, []string{"artifact_uuid", "organization_uuid", "gateway_uuid"}, - []string{"deployment_uuid", "status", "status_desired", "performed_at", "status_reason=NULL", "updated_at"}, + []string{"deployment_uuid", "status", "status_desired", "performed_at", "performed_by", "status_reason=NULL", "updated_at"}, ) // Status and UpdatedAt are guaranteed to be non-nil by initialization at function start @@ -181,6 +181,7 @@ func (r *DeploymentRepo) CreateWithLimitEnforcement(deployment *model.Deployment *deployment.Status, string(*deployment.Status), *deployment.UpdatedAt, + deployment.CreatedBy, nil, *deployment.UpdatedAt, ) @@ -340,7 +341,7 @@ func (r *DeploymentRepo) SetCurrent(artifactUUID, orgUUID, gatewayID, deployment // statusReason is an optional error code (cleared on new deployments). // Also maintains artifact_secret_refs (gateway_id rows): inserts refs on DEPLOYED, deletes them otherwise. func (r *DeploymentRepo) SetCurrentWithDetails(artifactUUID, orgUUID, gatewayID, deploymentID string, status model.DeploymentStatus, statusDesired string, performedAt *time.Time, statusReason string) (time.Time, error) { - updatedAt := time.Now() + updatedAt := time.Now().UTC() var pat time.Time if performedAt != nil { pat = *performedAt @@ -452,7 +453,7 @@ func (r *DeploymentRepo) UpdateStatusWithPerformedAtGuard(artifactUUID, orgUUID, reasonVal = statusReason } - updatedAt := time.Now() + updatedAt := time.Now().UTC() if len(requireCurrentStatus) > 0 { placeholders := make([]string, len(requireCurrentStatus)) @@ -495,7 +496,7 @@ func (r *DeploymentRepo) UpdateStatusWithPerformedAtGuard(artifactUUID, orgUUID, // GetStaleTransitionalStatuses finds deployment_status rows stuck in DEPLOYING/UNDEPLOYING // for longer than the given timeout duration. func (r *DeploymentRepo) GetStaleTransitionalStatuses(timeout time.Duration) ([]StaleDeploymentStatus, error) { - cutoff := time.Now().Add(-timeout) + cutoff := time.Now().UTC().Add(-timeout) query := ` SELECT artifact_uuid, organization_uuid, gateway_uuid, deployment_uuid, status, status_desired, performed_at FROM deployment_status diff --git a/platform-api/internal/repository/gateway.go b/platform-api/internal/repository/gateway.go index 60bbebfed5..e4e54487b8 100644 --- a/platform-api/internal/repository/gateway.go +++ b/platform-api/internal/repository/gateway.go @@ -42,8 +42,8 @@ func NewGatewayRepo(db *database.DB) GatewayRepository { // Create inserts a new gateway func (r *GatewayRepo) Create(gateway *model.Gateway) error { - gateway.CreatedAt = time.Now() - gateway.UpdatedAt = time.Now() + gateway.CreatedAt = time.Now().UTC() + gateway.UpdatedAt = time.Now().UTC() gateway.IsActive = false // Set default value to false at registration // Serialize properties to JSON bytes (BYTEA/BLOB column) @@ -346,6 +346,8 @@ func (r *GatewayRepo) Delete(gatewayID, organizationID string) error { // UpdateGateway updates gateway details func (r *GatewayRepo) UpdateGateway(gateway *model.Gateway) error { + gateway.UpdatedAt = time.Now().UTC() + var propertiesBytes []byte if gateway.Properties != nil { var err error @@ -401,13 +403,13 @@ func (r *GatewayRepo) UpdateActiveStatus(gatewayId string, isActive bool) error SET is_active = ?, updated_at = ? WHERE uuid = ? ` - _, err := r.db.Exec(r.db.Rebind(query), isActiveInt, time.Now(), gatewayId) + _, err := r.db.Exec(r.db.Rebind(query), isActiveInt, time.Now().UTC(), gatewayId) return err } // CreateToken inserts a new token func (r *GatewayRepo) CreateToken(token *model.GatewayToken) error { - token.CreatedAt = time.Now() + token.CreatedAt = time.Now().UTC() query := ` INSERT INTO gateway_tokens (uuid, gateway_uuid, token_hash, salt, status, created_by, created_at, revoked_by, revoked_at) @@ -516,7 +518,7 @@ func (r *GatewayRepo) GetTokenByUUID(tokenId string) (*model.GatewayToken, error // RevokeToken updates token status to revoked func (r *GatewayRepo) RevokeToken(tokenId, revokedBy string) error { - now := time.Now() + now := time.Now().UTC() var revokedByVal interface{} if revokedBy != "" { revokedByVal = revokedBy @@ -591,7 +593,7 @@ func (r *GatewayRepo) UpdateGatewayManifest(gatewayID string, manifest []byte) e // UpdateGatewayVersion persists the version string reported by the gateway controller on manifest push. func (r *GatewayRepo) UpdateGatewayVersion(gatewayID, version string) error { query := `UPDATE gateways SET version = ?, updated_at = ? WHERE uuid = ?` - _, err := r.db.Exec(r.db.Rebind(query), version, time.Now(), gatewayID) + _, err := r.db.Exec(r.db.Rebind(query), version, time.Now().UTC(), gatewayID) return err } diff --git a/platform-api/internal/repository/gateway_test.go b/platform-api/internal/repository/gateway_test.go new file mode 100644 index 0000000000..bd32009bdb --- /dev/null +++ b/platform-api/internal/repository/gateway_test.go @@ -0,0 +1,86 @@ +/* + * 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 ( + "testing" + "time" + + "github.com/wso2/api-platform/platform-api/internal/model" + + _ "github.com/mattn/go-sqlite3" +) + +// TestGatewayRepo_UpdateGateway_NormalizesUpdatedAtToUTC guards against a prior +// gap where UpdateGateway persisted whatever UpdatedAt the caller passed in +// (typically local-server-time time.Now()) instead of normalizing to UTC like +// Create does, leaving a gateway's created_at (UTC) and updated_at (local) +// in different timezone conventions after any edit. +func TestGatewayRepo_UpdateGateway_NormalizesUpdatedAtToUTC(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + orgUUID := "org-gw-utc" + projectUUID := "project-gw-utc" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + + repo := NewGatewayRepo(db) + gateway := &model.Gateway{ + ID: "gw-update-utc", + OrganizationID: orgUUID, + Handle: "gw-update-utc", + Name: "Gateway UTC Update", + } + if err := repo.Create(gateway); err != nil { + t.Fatalf("Create failed: %v", err) + } + if gateway.CreatedAt.Location() != time.UTC { + t.Fatalf("expected Create to set CreatedAt in UTC, got location %v", gateway.CreatedAt.Location()) + } + + // Deliberately pass a non-UTC UpdatedAt, mimicking a caller that computed + // time.Now() (server-local time) instead of time.Now().UTC(). + localZone := time.FixedZone("Test/Local", 5*60*60) + gateway.Name = "Gateway UTC Update (renamed)" + gateway.UpdatedAt = time.Date(2020, 1, 1, 0, 0, 0, 0, localZone) + + if err := repo.UpdateGateway(gateway); err != nil { + t.Fatalf("UpdateGateway failed: %v", err) + } + + if gateway.UpdatedAt.Location() != time.UTC { + t.Fatalf("expected UpdateGateway to normalize UpdatedAt to UTC, got location %v", gateway.UpdatedAt.Location()) + } + if time.Since(gateway.UpdatedAt) > time.Minute { + t.Fatalf("expected UpdatedAt to be normalized to roughly now, got %v", gateway.UpdatedAt) + } + + updated, err := repo.GetByHandleAndOrgID(gateway.Handle, orgUUID) + if err != nil { + t.Fatalf("GetByHandleAndOrgID failed: %v", err) + } + if updated == nil { + t.Fatal("GetByHandleAndOrgID returned nil") + } + if updated.Name != "Gateway UTC Update (renamed)" { + t.Fatalf("expected updated name to persist, got %q", updated.Name) + } + if updated.UpdatedAt.Year() == 2020 { + t.Fatalf("expected the persisted UpdatedAt to be the normalized value, not the caller-supplied 2020 date: %v", updated.UpdatedAt) + } +} diff --git a/platform-api/internal/repository/interfaces.go b/platform-api/internal/repository/interfaces.go index a757169a65..e09724ac67 100644 --- a/platform-api/internal/repository/interfaces.go +++ b/platform-api/internal/repository/interfaces.go @@ -35,6 +35,8 @@ type OrganizationRepository interface { DeleteOrganization(orgId string) error ListOrganizations(limit, offset int) ([]*model.Organization, error) CountOrganizations() (int, error) + ListOrganizationsForUser(userUUID string, limit, offset int) ([]*model.Organization, error) + CountOrganizationsForUser(userUUID string) (int, error) } // ProjectRepository defines the interface for project data access @@ -116,7 +118,7 @@ type APIRepository interface { // Unified API association methods (supports both gateways and dev portals) CreateAPIAssociation(association *model.APIAssociation) error GetAPIAssociations(apiUUID, associationType, orgUUID string) ([]*model.APIAssociation, error) - UpdateAPIAssociation(apiUUID, resourceId, associationType, orgUUID string) error + UpdateAPIAssociation(apiUUID, resourceId, associationType, orgUUID, updatedBy string) error // API name validation methods CheckAPIExistsByHandleInOrganization(handle, orgUUID string) (bool, error) @@ -250,14 +252,14 @@ type LLMProviderRepository interface { Exists(providerID, orgUUID string) (bool, error) // EnsureGatewayAssociation creates a gateway association for the provider if one // does not already exist and resolves the metadata to use for the deployment. - EnsureGatewayAssociation(providerUUID, gatewayUUID, orgUUID, deployMetadata string, metadataProvided bool) (string, error) + EnsureGatewayAssociation(providerUUID, gatewayUUID, orgUUID, createdBy, deployMetadata string, metadataProvided bool) (string, error) } // APIKeyRepository defines the interface for API key persistence type APIKeyRepository interface { Create(key *model.APIKey) error Update(key *model.APIKey) error - Revoke(artifactUUID, name string) error + Revoke(artifactUUID, name, updatedBy string) error GetByArtifactAndName(artifactUUID, name string) (*model.APIKey, error) ListByArtifact(artifactUUID string) ([]*model.APIKey, error) ListByGatewayAndKind(gatewayID, orgID, kind, issuer string) ([]*model.APIKey, error) @@ -280,7 +282,7 @@ type LLMProxyRepository interface { Exists(proxyID, orgUUID string) (bool, error) // EnsureGatewayAssociation creates a gateway association for the proxy if one does // not already exist and resolves the metadata to use for the deployment. - EnsureGatewayAssociation(proxyUUID, gatewayUUID, orgUUID, deployMetadata string, metadataProvided bool) (string, error) + EnsureGatewayAssociation(proxyUUID, gatewayUUID, orgUUID, createdBy, deployMetadata string, metadataProvided bool) (string, error) } // MCPProxyRepository defines the interface for MCP proxy persistence @@ -295,7 +297,7 @@ type MCPProxyRepository interface { Update(p *model.MCPProxy) error Delete(handle, orgUUID string) error Exists(handle, orgUUID string) (bool, error) - EnsureGatewayAssociation(proxyUUID, gatewayUUID, orgUUID, deployMetadata string, metadataProvided bool) (string, error) + EnsureGatewayAssociation(proxyUUID, gatewayUUID, orgUUID, createdBy, deployMetadata string, metadataProvided bool) (string, error) } // WebSubAPIHmacSecretRepository defines the interface for WebSub API HMAC secret persistence @@ -382,10 +384,10 @@ type UserIdentityMappingRepository interface { } // UserOrganizationMappingRepository defines the interface for user<->organization -// membership persistence. Populate-only today (no reader depends on it). Both -// FKs are declared without ON DELETE CASCADE; DeleteByUser/DeleteByOrg exist so -// callers can perform the cascade in application code, in the same transaction -// as the parent delete. +// membership persistence. Both FKs are declared ON DELETE CASCADE in the +// schema; DeleteByUser/DeleteByOrg additionally perform the same deletes in +// application code, in the same transaction as the parent delete, as +// defense-in-depth for pooled SQLite connections that may not enforce FKs. type UserOrganizationMappingRepository interface { // AddMembership records that userUUID has onboarded to orgUUID. Idempotent: // a duplicate (userUUID, orgUUID) pair is a no-op, not an error. diff --git a/platform-api/internal/repository/llm.go b/platform-api/internal/repository/llm.go index 656acdd4e4..7f692408b4 100644 --- a/platform-api/internal/repository/llm.go +++ b/platform-api/internal/repository/llm.go @@ -70,7 +70,7 @@ func (r *LLMProviderTemplateRepo) Create(t *model.LLMProviderTemplate) error { // Preserve caller-provided timestamps: the DP->CP import sets created_at/updated_at to the // gateway deployment time (UTC) so they act as the last-in-wins watermark. Default to now // for control-plane-native creates that leave them unset. - now := time.Now() + now := time.Now().UTC() if t.CreatedAt.IsZero() { t.CreatedAt = now } @@ -197,7 +197,7 @@ func (r *LLMProviderTemplateRepo) createNewVersionOnce(t *model.LLMProviderTempl t.IsLatest = true t.Enabled = true t.UpdatedBy = t.CreatedBy - now := time.Now() + now := time.Now().UTC() if t.CreatedAt.IsZero() { t.CreatedAt = now } @@ -437,7 +437,7 @@ func (r *LLMProviderTemplateRepo) Update(t *model.LLMProviderTemplate) error { // time (UTC) so it acts as the last-in-wins watermark. Default to now for control-plane-native // updates that leave it unset. if t.UpdatedAt.IsZero() { - t.UpdatedAt = time.Now() + t.UpdatedAt = time.Now().UTC() } configJSON, err := json.Marshal(&llmProviderTemplateConfig{ @@ -481,7 +481,7 @@ func (r *LLMProviderTemplateRepo) RenameFamily(baseHandle, orgUUID, name string) _, err := r.db.Exec(r.db.Rebind(` UPDATE llm_provider_templates SET display_name = ?, updated_at = ? WHERE group_id = ? AND organization_uuid = ? AND managed_by != ? - `), name, time.Now(), baseHandle, orgUUID, "wso2") + `), name, time.Now().UTC(), baseHandle, orgUUID, "wso2") return err } @@ -489,7 +489,7 @@ func (r *LLMProviderTemplateRepo) SetEnabled(groupID, orgUUID, version string, e result, err := r.db.Exec(r.db.Rebind(` UPDATE llm_provider_templates SET enabled = ?, updated_at = ? WHERE group_id = ? AND organization_uuid = ? AND version = ? - `), boolToInt(enabled), time.Now(), groupID, orgUUID, version) + `), boolToInt(enabled), time.Now().UTC(), groupID, orgUUID, version) if err != nil { return err } @@ -593,21 +593,21 @@ func (r *LLMProviderTemplateRepo) CountProvidersUsingTemplate(groupID, orgUUID, // insertArtifactGatewayAssociations writes the given gateway associations for an artifact // within the supplied transaction. metadata is a BYTEA column; a nil slice is stored as NULL. -func insertArtifactGatewayAssociations(tx *sql.Tx, db *database.DB, artifactUUID, orgUUID string, assocs []model.AssociatedGatewayMapping, now time.Time) error { +func insertArtifactGatewayAssociations(tx *sql.Tx, db *database.DB, artifactUUID, orgUUID, createdBy string, assocs []model.AssociatedGatewayMapping, now time.Time) error { if len(assocs) == 0 { return nil } query := ` INSERT INTO artifact_gateway_mappings ( - artifact_uuid, organization_uuid, gateway_uuid, metadata, created_at, updated_at + artifact_uuid, organization_uuid, gateway_uuid, metadata, created_by, updated_by, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` for _, assoc := range assocs { var metadata []byte if assoc.Metadata != "" { metadata = []byte(assoc.Metadata) } - if _, err := tx.Exec(db.Rebind(query), artifactUUID, orgUUID, assoc.GatewayUUID, metadata, now, now); err != nil { + if _, err := tx.Exec(db.Rebind(query), artifactUUID, orgUUID, assoc.GatewayUUID, metadata, createdBy, createdBy, now, now); err != nil { return fmt.Errorf("failed to create gateway association: %w", err) } } @@ -616,13 +616,13 @@ func insertArtifactGatewayAssociations(tx *sql.Tx, db *database.DB, artifactUUID // replaceArtifactGatewayAssociations replaces the full set of gateway associations for an // artifact within the supplied transaction (delete-all then insert). Deployments are not touched. -func replaceArtifactGatewayAssociations(tx *sql.Tx, db *database.DB, artifactUUID, orgUUID string, assocs []model.AssociatedGatewayMapping, now time.Time) error { +func replaceArtifactGatewayAssociations(tx *sql.Tx, db *database.DB, artifactUUID, orgUUID, updatedBy string, assocs []model.AssociatedGatewayMapping, now time.Time) error { if _, err := tx.Exec(db.Rebind( `DELETE FROM artifact_gateway_mappings WHERE artifact_uuid = ? AND organization_uuid = ?`), artifactUUID, orgUUID); err != nil { return fmt.Errorf("failed to clear gateway associations: %w", err) } - return insertArtifactGatewayAssociations(tx, db, artifactUUID, orgUUID, assocs, now) + return insertArtifactGatewayAssociations(tx, db, artifactUUID, orgUUID, updatedBy, assocs, now) } // loadArtifactGatewayAssociations returns the gateway associations for an artifact, joining @@ -667,7 +667,7 @@ func loadArtifactGatewayAssociations(db *database.DB, artifactUUID, orgUUID stri // - Association exists, metadata omitted → fall back to the association's stored metadata. // // It returns the metadata to persist on the deployment record. An empty string means "no metadata". -func ensureArtifactGatewayAssociation(db *database.DB, artifactUUID, gatewayUUID, orgUUID, deployMetadata string, metadataProvided bool) (string, error) { +func ensureArtifactGatewayAssociation(db *database.DB, artifactUUID, gatewayUUID, orgUUID, createdBy, deployMetadata string, metadataProvided bool) (string, error) { effectiveMetadata := func(existing []byte) string { if metadataProvided { return deployMetadata @@ -688,17 +688,17 @@ func ensureArtifactGatewayAssociation(db *database.DB, artifactUUID, gatewayUUID } // No association yet → insert one, seeding its metadata from the deploy request. - now := time.Now() + now := time.Now().UTC() var metaArg []byte if strings.TrimSpace(deployMetadata) != "" { metaArg = []byte(deployMetadata) } insertQuery := ` INSERT INTO artifact_gateway_mappings ( - artifact_uuid, organization_uuid, gateway_uuid, metadata, created_at, updated_at + artifact_uuid, organization_uuid, gateway_uuid, metadata, created_by, updated_by, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?)` - if _, err := db.Exec(db.Rebind(insertQuery), artifactUUID, orgUUID, gatewayUUID, metaArg, now, now); err != nil { + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + if _, err := db.Exec(db.Rebind(insertQuery), artifactUUID, orgUUID, gatewayUUID, metaArg, createdBy, createdBy, now, now); err != nil { // A concurrent deploy for the same artifact/gateway may have inserted the row // between our read and this insert, tripping the primary key. Re-read: if the row // now exists the ensure has effectively succeeded (idempotent); otherwise the @@ -746,7 +746,7 @@ func (r *LLMProviderRepo) Create(p *model.LLMProvider) error { return fmt.Errorf("failed to generate LLM provider ID: %w", err) } p.UUID = uuidStr - now := time.Now() + now := time.Now().UTC() p.CreatedAt = now p.UpdatedAt = now @@ -787,12 +787,12 @@ func (r *LLMProviderRepo) Create(p *model.LLMProvider) error { // Insert into llm_providers table (handle/name/version/timestamps now live here) query := ` INSERT INTO llm_providers ( - uuid, handle, display_name, version, description, created_by, template_uuid, openapi_spec, model_list, + uuid, handle, display_name, version, description, created_by, updated_by, template_uuid, openapi_spec, model_list, configuration, origin, data_version, created_at, updated_at, organization_uuid ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` _, err = tx.Exec(r.db.Rebind(query), - p.UUID, p.ID, p.Name, p.Version, p.Description, p.CreatedBy, p.TemplateUUID, + p.UUID, p.ID, p.Name, p.Version, p.Description, p.CreatedBy, p.UpdatedBy, p.TemplateUUID, []byte(p.OpenAPISpec), modelProvidersJSON, configurationJSON, origin, p.DataVersion, p.CreatedAt, p.UpdatedAt, p.OrganizationUUID, ) @@ -805,7 +805,7 @@ func (r *LLMProviderRepo) Create(p *model.LLMProvider) error { } // Persist gateway associations (if any) within the same transaction. - if err := insertArtifactGatewayAssociations(tx, r.db, p.UUID, p.OrganizationUUID, p.AssociatedGateways, now); err != nil { + if err := insertArtifactGatewayAssociations(tx, r.db, p.UUID, p.OrganizationUUID, p.CreatedBy, p.AssociatedGateways, now); err != nil { return err } @@ -819,18 +819,18 @@ func (r *LLMProviderRepo) GetByID(providerID, orgUUID string) (*model.LLMProvide query := ` SELECT uuid, handle, display_name, version, organization_uuid, origin, data_version, created_at, updated_at, - description, created_by, template_uuid, openapi_spec, model_list, configuration + description, created_by, updated_by, template_uuid, openapi_spec, model_list, configuration FROM llm_providers WHERE handle = ? AND organization_uuid = ?` row := r.db.QueryRow(r.db.Rebind(query), providerID, orgUUID) var p model.LLMProvider - var createdBy sql.NullString + var createdBy, updatedBy sql.NullString var openAPISpec, modelProvidersRaw []byte var configurationJSON []byte if err := row.Scan( &p.UUID, &p.ID, &p.Name, &p.Version, &p.OrganizationUUID, &p.Origin, &p.DataVersion, &p.CreatedAt, &p.UpdatedAt, - &p.Description, &createdBy, &p.TemplateUUID, &openAPISpec, &modelProvidersRaw, &configurationJSON, + &p.Description, &createdBy, &updatedBy, &p.TemplateUUID, &openAPISpec, &modelProvidersRaw, &configurationJSON, ); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil @@ -838,6 +838,7 @@ func (r *LLMProviderRepo) GetByID(providerID, orgUUID string) (*model.LLMProvide return nil, err } p.CreatedBy = createdBy.String + p.UpdatedBy = updatedBy.String if len(configurationJSON) > 0 { if config, err := deserializeLLMProviderConfiguration(configurationJSON); err != nil { @@ -868,8 +869,8 @@ func (r *LLMProviderRepo) GetByID(providerID, orgUUID string) (*model.LLMProvide // EnsureGatewayAssociation creates a gateway association for the provider if one does not // already exist and resolves the metadata to use for the deployment. See // ensureArtifactGatewayAssociation for the full semantics. -func (r *LLMProviderRepo) EnsureGatewayAssociation(providerUUID, gatewayUUID, orgUUID, deployMetadata string, metadataProvided bool) (string, error) { - return ensureArtifactGatewayAssociation(r.db, providerUUID, gatewayUUID, orgUUID, deployMetadata, metadataProvided) +func (r *LLMProviderRepo) EnsureGatewayAssociation(providerUUID, gatewayUUID, orgUUID, createdBy, deployMetadata string, metadataProvided bool) (string, error) { + return ensureArtifactGatewayAssociation(r.db, providerUUID, gatewayUUID, orgUUID, createdBy, deployMetadata, metadataProvided) } func (r *LLMProviderRepo) List(orgUUID string, limit, offset int) ([]*model.LLMProvider, error) { @@ -878,7 +879,7 @@ func (r *LLMProviderRepo) List(orgUUID string, limit, offset int) ([]*model.LLMP query := ` SELECT uuid, handle, display_name, version, organization_uuid, origin, data_version, created_at, updated_at, - description, created_by, template_uuid, openapi_spec, model_list, configuration + description, created_by, updated_by, template_uuid, openapi_spec, model_list, configuration FROM llm_providers WHERE organization_uuid = ? ORDER BY created_at DESC @@ -892,17 +893,18 @@ func (r *LLMProviderRepo) List(orgUUID string, limit, offset int) ([]*model.LLMP var res []*model.LLMProvider for rows.Next() { var p model.LLMProvider - var createdBy sql.NullString + var createdBy, updatedBy sql.NullString var openAPISpec, modelProvidersRaw []byte var configurationJSON []byte err := rows.Scan( &p.UUID, &p.ID, &p.Name, &p.Version, &p.OrganizationUUID, &p.Origin, &p.DataVersion, &p.CreatedAt, &p.UpdatedAt, - &p.Description, &createdBy, &p.TemplateUUID, &openAPISpec, &modelProvidersRaw, &configurationJSON, + &p.Description, &createdBy, &updatedBy, &p.TemplateUUID, &openAPISpec, &modelProvidersRaw, &configurationJSON, ) if err != nil { return nil, err } p.CreatedBy = createdBy.String + p.UpdatedBy = updatedBy.String if len(openAPISpec) > 0 { p.OpenAPISpec = string(openAPISpec) } @@ -928,7 +930,7 @@ func (r *LLMProviderRepo) Count(orgUUID string) (int, error) { } func (r *LLMProviderRepo) Update(p *model.LLMProvider) error { - now := time.Now() + now := time.Now().UTC() p.UpdatedAt = now modelProvidersJSON, err := json.Marshal(p.ModelProviders) @@ -996,7 +998,7 @@ func (r *LLMProviderRepo) Update(p *model.LLMProvider) error { // Replace the full set of gateway associations within the same transaction when the // caller manages associations. Deployments are intentionally left untouched. if p.ReplaceAssociatedGateways { - if err := replaceArtifactGatewayAssociations(tx, r.db, providerUUID, p.OrganizationUUID, p.AssociatedGateways, now); err != nil { + if err := replaceArtifactGatewayAssociations(tx, r.db, providerUUID, p.OrganizationUUID, p.UpdatedBy, p.AssociatedGateways, now); err != nil { return err } } @@ -1064,7 +1066,7 @@ func (r *LLMProxyRepo) Create(p *model.LLMProxy) error { return fmt.Errorf("failed to generate LLM proxy ID: %w", err) } p.UUID = uuidStr - now := time.Now() + now := time.Now().UTC() p.CreatedAt = now p.UpdatedAt = now @@ -1100,12 +1102,12 @@ func (r *LLMProxyRepo) Create(p *model.LLMProxy) error { // Insert into llm_proxies table (handle/name/version/timestamps now live here) query := ` INSERT INTO llm_proxies ( - uuid, handle, display_name, version, project_uuid, description, created_by, provider_uuid, openapi_spec, + uuid, handle, display_name, version, project_uuid, description, created_by, updated_by, provider_uuid, openapi_spec, configuration, origin, data_version, created_at, updated_at, organization_uuid ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` _, err = tx.Exec(r.db.Rebind(query), - p.UUID, p.ID, p.Name, p.Version, p.ProjectUUID, p.Description, p.CreatedBy, p.ProviderUUID, + p.UUID, p.ID, p.Name, p.Version, p.ProjectUUID, p.Description, p.CreatedBy, p.UpdatedBy, p.ProviderUUID, []byte(p.OpenAPISpec), configurationJSON, origin, p.DataVersion, p.CreatedAt, p.UpdatedAt, p.OrganizationUUID, ) @@ -1118,7 +1120,7 @@ func (r *LLMProxyRepo) Create(p *model.LLMProxy) error { } // Persist gateway associations (if any) within the same transaction. - if err := insertArtifactGatewayAssociations(tx, r.db, p.UUID, p.OrganizationUUID, p.AssociatedGateways, now); err != nil { + if err := insertArtifactGatewayAssociations(tx, r.db, p.UUID, p.OrganizationUUID, p.CreatedBy, p.AssociatedGateways, now); err != nil { return err } @@ -1132,17 +1134,17 @@ func (r *LLMProxyRepo) GetByID(proxyID, orgUUID string) (*model.LLMProxy, error) query := ` SELECT uuid, handle, display_name, version, organization_uuid, origin, data_version, created_at, updated_at, - project_uuid, description, created_by, provider_uuid, openapi_spec, configuration + project_uuid, description, created_by, updated_by, provider_uuid, openapi_spec, configuration FROM llm_proxies WHERE handle = ? AND organization_uuid = ?` row := r.db.QueryRow(r.db.Rebind(query), proxyID, orgUUID) var p model.LLMProxy - var createdBy sql.NullString + var createdBy, updatedBy sql.NullString var openAPISpec, configurationJSON []byte if err := row.Scan( &p.UUID, &p.ID, &p.Name, &p.Version, &p.OrganizationUUID, &p.Origin, &p.DataVersion, &p.CreatedAt, &p.UpdatedAt, - &p.ProjectUUID, &p.Description, &createdBy, &p.ProviderUUID, + &p.ProjectUUID, &p.Description, &createdBy, &updatedBy, &p.ProviderUUID, &openAPISpec, &configurationJSON, ); err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -1151,6 +1153,7 @@ func (r *LLMProxyRepo) GetByID(proxyID, orgUUID string) (*model.LLMProxy, error) return nil, err } p.CreatedBy = createdBy.String + p.UpdatedBy = updatedBy.String if len(openAPISpec) > 0 { p.OpenAPISpec = string(openAPISpec) @@ -1178,7 +1181,7 @@ func (r *LLMProxyRepo) List(orgUUID string, limit, offset int) ([]*model.LLMProx query := ` SELECT uuid, handle, display_name, version, organization_uuid, origin, data_version, created_at, updated_at, - project_uuid, description, created_by, provider_uuid, + project_uuid, description, created_by, updated_by, provider_uuid, openapi_spec, configuration FROM llm_proxies WHERE organization_uuid = ? @@ -1193,17 +1196,18 @@ func (r *LLMProxyRepo) List(orgUUID string, limit, offset int) ([]*model.LLMProx var res []*model.LLMProxy for rows.Next() { var p model.LLMProxy - var createdBy sql.NullString + var createdBy, updatedBy sql.NullString var openAPISpec, configurationJSON []byte err := rows.Scan( &p.UUID, &p.ID, &p.Name, &p.Version, &p.OrganizationUUID, &p.Origin, &p.DataVersion, &p.CreatedAt, &p.UpdatedAt, - &p.ProjectUUID, &p.Description, &createdBy, &p.ProviderUUID, + &p.ProjectUUID, &p.Description, &createdBy, &updatedBy, &p.ProviderUUID, &openAPISpec, &configurationJSON, ) if err != nil { return nil, err } p.CreatedBy = createdBy.String + p.UpdatedBy = updatedBy.String if len(openAPISpec) > 0 { p.OpenAPISpec = string(openAPISpec) } @@ -1225,7 +1229,7 @@ func (r *LLMProxyRepo) ListByProject(orgUUID, projectUUID string, limit, offset query := ` SELECT uuid, handle, display_name, version, organization_uuid, origin, data_version, created_at, updated_at, - project_uuid, description, created_by, provider_uuid, + project_uuid, description, created_by, updated_by, provider_uuid, openapi_spec, configuration FROM llm_proxies WHERE organization_uuid = ? AND project_uuid = ? @@ -1240,17 +1244,18 @@ func (r *LLMProxyRepo) ListByProject(orgUUID, projectUUID string, limit, offset var res []*model.LLMProxy for rows.Next() { var p model.LLMProxy - var createdBy sql.NullString + var createdBy, updatedBy sql.NullString var openAPISpec, configurationJSON []byte err := rows.Scan( &p.UUID, &p.ID, &p.Name, &p.Version, &p.OrganizationUUID, &p.Origin, &p.DataVersion, &p.CreatedAt, &p.UpdatedAt, - &p.ProjectUUID, &p.Description, &createdBy, &p.ProviderUUID, + &p.ProjectUUID, &p.Description, &createdBy, &updatedBy, &p.ProviderUUID, &openAPISpec, &configurationJSON, ) if err != nil { return nil, err } p.CreatedBy = createdBy.String + p.UpdatedBy = updatedBy.String if len(openAPISpec) > 0 { p.OpenAPISpec = string(openAPISpec) } @@ -1272,7 +1277,7 @@ func (r *LLMProxyRepo) ListByProvider(orgUUID, providerUUID string, limit, offse query := ` SELECT uuid, handle, display_name, version, organization_uuid, origin, data_version, created_at, updated_at, - project_uuid, description, created_by, provider_uuid, + project_uuid, description, created_by, updated_by, provider_uuid, openapi_spec, configuration FROM llm_proxies WHERE organization_uuid = ? AND provider_uuid = ? @@ -1287,17 +1292,18 @@ func (r *LLMProxyRepo) ListByProvider(orgUUID, providerUUID string, limit, offse var res []*model.LLMProxy for rows.Next() { var p model.LLMProxy - var createdBy sql.NullString + var createdBy, updatedBy sql.NullString var openAPISpec, configurationJSON []byte err := rows.Scan( &p.UUID, &p.ID, &p.Name, &p.Version, &p.OrganizationUUID, &p.Origin, &p.DataVersion, &p.CreatedAt, &p.UpdatedAt, - &p.ProjectUUID, &p.Description, &createdBy, &p.ProviderUUID, + &p.ProjectUUID, &p.Description, &createdBy, &updatedBy, &p.ProviderUUID, &openAPISpec, &configurationJSON, ) if err != nil { return nil, err } p.CreatedBy = createdBy.String + p.UpdatedBy = updatedBy.String if len(openAPISpec) > 0 { p.OpenAPISpec = string(openAPISpec) } @@ -1341,7 +1347,7 @@ func (r *LLMProxyRepo) CountByProvider(orgUUID, providerUUID string) (int, error } func (r *LLMProxyRepo) Update(p *model.LLMProxy) error { - now := time.Now() + now := time.Now().UTC() p.UpdatedAt = now configurationJSON, err := serializeLLMProxyConfiguration(p.Configuration) @@ -1407,7 +1413,7 @@ func (r *LLMProxyRepo) Update(p *model.LLMProxy) error { // Replace the full set of gateway associations within the same transaction when the // caller manages associations. Deployments are intentionally left untouched. if p.ReplaceAssociatedGateways { - if err := replaceArtifactGatewayAssociations(tx, r.db, proxyUUID, p.OrganizationUUID, p.AssociatedGateways, now); err != nil { + if err := replaceArtifactGatewayAssociations(tx, r.db, proxyUUID, p.OrganizationUUID, p.UpdatedBy, p.AssociatedGateways, now); err != nil { return err } } @@ -1421,8 +1427,8 @@ func (r *LLMProxyRepo) Update(p *model.LLMProxy) error { // EnsureGatewayAssociation creates a gateway association for the proxy if one does not // already exist and resolves the metadata to use for the deployment. See // ensureArtifactGatewayAssociation for the full semantics. -func (r *LLMProxyRepo) EnsureGatewayAssociation(proxyUUID, gatewayUUID, orgUUID, deployMetadata string, metadataProvided bool) (string, error) { - return ensureArtifactGatewayAssociation(r.db, proxyUUID, gatewayUUID, orgUUID, deployMetadata, metadataProvided) +func (r *LLMProxyRepo) EnsureGatewayAssociation(proxyUUID, gatewayUUID, orgUUID, createdBy, deployMetadata string, metadataProvided bool) (string, error) { + return ensureArtifactGatewayAssociation(r.db, proxyUUID, gatewayUUID, orgUUID, createdBy, deployMetadata, metadataProvided) } func (r *LLMProxyRepo) Delete(proxyID, orgUUID string) error { diff --git a/platform-api/internal/repository/llm_audit_test.go b/platform-api/internal/repository/llm_audit_test.go new file mode 100644 index 0000000000..9343ed3a6d --- /dev/null +++ b/platform-api/internal/repository/llm_audit_test.go @@ -0,0 +1,163 @@ +/* + * 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 ( + "testing" + + "github.com/wso2/api-platform/platform-api/internal/model" + + _ "github.com/mattn/go-sqlite3" +) + +// TestLLMProviderRepo_CreateAndRead_SetsUpdatedBy guards against a prior gap +// where the llm_providers INSERT omitted updated_by, and GetByID/List never +// selected it back even after it was persisted. +func TestLLMProviderRepo_CreateAndRead_SetsUpdatedBy(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + orgUUID := "org-llm-provider-updatedby" + projectUUID := "project-llm-provider-updatedby" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + + templateRepo := NewLLMProviderTemplateRepo(db) + template := &model.LLMProviderTemplate{ + OrganizationUUID: orgUUID, + ID: "test-template", + GroupID: "test-template", + Name: "Test Template", + ManagedBy: "wso2", + Version: "v1.0", + } + if err := templateRepo.Create(template); err != nil { + t.Fatalf("failed to create template fixture: %v", err) + } + + providerRepo := NewLLMProviderRepo(db) + provider := &model.LLMProvider{ + OrganizationUUID: orgUUID, + ID: "test-provider", + Name: "Test Provider", + Version: "v1.0", + TemplateUUID: template.UUID, + CreatedBy: "test-user", + UpdatedBy: "test-user", + } + if err := providerRepo.Create(provider); err != nil { + t.Fatalf("Create failed: %v", err) + } + + got, err := providerRepo.GetByID(provider.ID, orgUUID) + if err != nil { + t.Fatalf("GetByID failed: %v", err) + } + if got == nil { + t.Fatal("GetByID returned nil") + } + if got.UpdatedBy == "" { + t.Fatal("expected updated_by to be set on creation, got empty string") + } + if got.UpdatedBy != got.CreatedBy { + t.Fatalf("expected updated_by == created_by on creation, got created_by=%q updated_by=%q", got.CreatedBy, got.UpdatedBy) + } + + list, err := providerRepo.List(orgUUID, 20, 0) + if err != nil { + t.Fatalf("List failed: %v", err) + } + if len(list) != 1 || list[0].UpdatedBy != "test-user" { + t.Fatalf("expected List to also return updated_by, got %+v", list) + } +} + +// TestLLMProxyRepo_CreateAndRead_SetsUpdatedBy guards against a prior gap +// where the llm_proxies INSERT omitted updated_by, and GetByID/List never +// selected it back even after it was persisted. +func TestLLMProxyRepo_CreateAndRead_SetsUpdatedBy(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + orgUUID := "org-llm-proxy-updatedby" + projectUUID := "project-llm-proxy-updatedby" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + + templateRepo := NewLLMProviderTemplateRepo(db) + template := &model.LLMProviderTemplate{ + OrganizationUUID: orgUUID, + ID: "test-template-proxy", + GroupID: "test-template-proxy", + Name: "Test Template", + ManagedBy: "wso2", + Version: "v1.0", + } + if err := templateRepo.Create(template); err != nil { + t.Fatalf("failed to create template fixture: %v", err) + } + + providerRepo := NewLLMProviderRepo(db) + provider := &model.LLMProvider{ + OrganizationUUID: orgUUID, + ID: "test-provider-for-proxy", + Name: "Test Provider", + Version: "v1.0", + TemplateUUID: template.UUID, + CreatedBy: "test-user", + UpdatedBy: "test-user", + } + if err := providerRepo.Create(provider); err != nil { + t.Fatalf("failed to create provider fixture: %v", err) + } + + proxyRepo := NewLLMProxyRepo(db) + proxy := &model.LLMProxy{ + OrganizationUUID: orgUUID, + ProjectUUID: projectUUID, + ID: "test-proxy", + Name: "Test Proxy", + Version: "v1.0", + ProviderUUID: provider.UUID, + CreatedBy: "test-user", + UpdatedBy: "test-user", + } + if err := proxyRepo.Create(proxy); err != nil { + t.Fatalf("Create failed: %v", err) + } + + got, err := proxyRepo.GetByID(proxy.ID, orgUUID) + if err != nil { + t.Fatalf("GetByID failed: %v", err) + } + if got == nil { + t.Fatal("GetByID returned nil") + } + if got.UpdatedBy == "" { + t.Fatal("expected updated_by to be set on creation, got empty string") + } + if got.UpdatedBy != got.CreatedBy { + t.Fatalf("expected updated_by == created_by on creation, got created_by=%q updated_by=%q", got.CreatedBy, got.UpdatedBy) + } + + list, err := proxyRepo.List(orgUUID, 20, 0) + if err != nil { + t.Fatalf("List failed: %v", err) + } + if len(list) != 1 || list[0].UpdatedBy != "test-user" { + t.Fatalf("expected List to also return updated_by, got %+v", list) + } +} diff --git a/platform-api/internal/repository/mcp.go b/platform-api/internal/repository/mcp.go index c09d31ea9f..2991c9f22f 100644 --- a/platform-api/internal/repository/mcp.go +++ b/platform-api/internal/repository/mcp.go @@ -103,7 +103,7 @@ func (r *MCPProxyRepo) Create(p *model.MCPProxy) error { } // Persist gateway associations (if any) within the same transaction. - if err := insertArtifactGatewayAssociations(tx, r.db, p.UUID, p.OrganizationUUID, p.AssociatedGateways, now); err != nil { + if err := insertArtifactGatewayAssociations(tx, r.db, p.UUID, p.OrganizationUUID, p.CreatedBy, p.AssociatedGateways, now); err != nil { return err } @@ -359,7 +359,7 @@ func (r *MCPProxyRepo) Update(p *model.MCPProxy) error { // Replace the full set of gateway associations within the same transaction when the // caller manages associations. Deployments are intentionally left untouched. if p.ReplaceAssociatedGateways { - if err := replaceArtifactGatewayAssociations(tx, r.db, proxyUUID, p.OrganizationUUID, p.AssociatedGateways, now); err != nil { + if err := replaceArtifactGatewayAssociations(tx, r.db, proxyUUID, p.OrganizationUUID, p.UpdatedBy, p.AssociatedGateways, now); err != nil { return err } } @@ -373,8 +373,8 @@ func (r *MCPProxyRepo) Update(p *model.MCPProxy) error { // EnsureGatewayAssociation creates a gateway association for the MCP proxy if one does not // already exist and resolves the metadata to use for the deployment. See // ensureArtifactGatewayAssociation for the full semantics. -func (r *MCPProxyRepo) EnsureGatewayAssociation(proxyUUID, gatewayUUID, orgUUID, deployMetadata string, metadataProvided bool) (string, error) { - return ensureArtifactGatewayAssociation(r.db, proxyUUID, gatewayUUID, orgUUID, deployMetadata, metadataProvided) +func (r *MCPProxyRepo) EnsureGatewayAssociation(proxyUUID, gatewayUUID, orgUUID, createdBy, deployMetadata string, metadataProvided bool) (string, error) { + return ensureArtifactGatewayAssociation(r.db, proxyUUID, gatewayUUID, orgUUID, createdBy, deployMetadata, metadataProvided) } // Delete deletes an MCP proxy by its handle and organization UUID diff --git a/platform-api/internal/repository/organization.go b/platform-api/internal/repository/organization.go index 6a7285a45e..782df9b0e5 100644 --- a/platform-api/internal/repository/organization.go +++ b/platform-api/internal/repository/organization.go @@ -39,8 +39,8 @@ func NewOrganizationRepo(db *database.DB) OrganizationRepository { // CreateOrganization inserts a new organization func (r *OrganizationRepo) CreateOrganization(org *model.Organization) error { - org.CreatedAt = time.Now() - org.UpdatedAt = time.Now() + org.CreatedAt = time.Now().UTC() + org.UpdatedAt = time.Now().UTC() query := ` INSERT INTO organizations (uuid, handle, display_name, region, idp_organization_ref_uuid, created_by, updated_by, created_at, updated_at) @@ -147,7 +147,7 @@ func (r *OrganizationRepo) GetOrganizationByIdpOrgRefUUID(idpOrgRefUUID string) } func (r *OrganizationRepo) UpdateOrganization(org *model.Organization) error { - org.UpdatedAt = time.Now() + org.UpdatedAt = time.Now().UTC() query := ` UPDATE organizations SET handle = ?, display_name = ?, region = ?, updated_by = ?, updated_at = ? @@ -164,9 +164,9 @@ func (r *OrganizationRepo) DeleteOrganization(orgId string) error { } defer tx.Rollback() - // user_organization_mappings has no ON DELETE CASCADE on org_uuid by - // design (see schema comment) — delete the membership rows first, in the - // same transaction, before deleting the organization itself. + // The FK on org_uuid is declared ON DELETE CASCADE, so this delete is + // normally redundant; it exists as defense-in-depth for pooled SQLite + // connections that may not enforce foreign keys. if err := r.userOrgMappingRepo.DeleteByOrg(tx, orgId); err != nil { return err } @@ -215,3 +215,46 @@ func (r *OrganizationRepo) CountOrganizations() (int, error) { } return total, nil } + +// ListOrganizationsForUser returns the organizations userUUID is a member of, +// per user_organization_mappings, newest first. +func (r *OrganizationRepo) ListOrganizationsForUser(userUUID string, limit, offset int) ([]*model.Organization, error) { + pageClause, pageArgs := r.db.PaginationClause(limit, offset) + query := ` + SELECT o.uuid, o.handle, o.display_name, o.region, o.idp_organization_ref_uuid, o.created_by, o.updated_by, o.created_at, o.updated_at + FROM organizations o + JOIN user_organization_mappings m ON m.org_uuid = o.uuid + WHERE m.user_uuid = ? + ORDER BY o.created_at DESC + ` + pageClause + args := append([]any{userUUID}, pageArgs...) + rows, err := r.db.Query(r.db.Rebind(query), args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var orgs []*model.Organization + for rows.Next() { + org := &model.Organization{} + var createdBy, updatedBy sql.NullString + if err := rows.Scan(&org.ID, &org.Handle, &org.Name, &org.Region, &org.IdpOrganizationRefUUID, &createdBy, &updatedBy, &org.CreatedAt, &org.UpdatedAt); err != nil { + return nil, err + } + org.CreatedBy = createdBy.String + org.UpdatedBy = updatedBy.String + orgs = append(orgs, org) + } + return orgs, rows.Err() +} + +// CountOrganizationsForUser returns the total membership count for userUUID, +// independent of any pagination applied by ListOrganizationsForUser. +func (r *OrganizationRepo) CountOrganizationsForUser(userUUID string) (int, error) { + var total int + query := `SELECT COUNT(*) FROM user_organization_mappings WHERE user_uuid = ?` + if err := r.db.QueryRow(r.db.Rebind(query), userUUID).Scan(&total); err != nil { + return 0, err + } + return total, nil +} diff --git a/platform-api/internal/repository/project.go b/platform-api/internal/repository/project.go index 623870fa83..b99aca657b 100644 --- a/platform-api/internal/repository/project.go +++ b/platform-api/internal/repository/project.go @@ -38,8 +38,8 @@ func NewProjectRepo(db *database.DB) ProjectRepository { // CreateProject inserts a new project func (r *ProjectRepo) CreateProject(project *model.Project) error { - project.CreatedAt = time.Now() - project.UpdatedAt = time.Now() + project.CreatedAt = time.Now().UTC() + project.UpdatedAt = time.Now().UTC() query := ` INSERT INTO projects (uuid, handle, display_name, organization_uuid, description, created_by, created_at, updated_by, updated_at) @@ -159,7 +159,7 @@ func (r *ProjectRepo) GetProjectsByOrganizationID(orgID string) ([]*model.Projec // UpdateProject modifies an existing project func (r *ProjectRepo) UpdateProject(project *model.Project) error { - project.UpdatedAt = time.Now() + project.UpdatedAt = time.Now().UTC() query := ` UPDATE projects SET display_name = ?, description = ?, updated_by = ?, updated_at = ? diff --git a/platform-api/internal/repository/secret.go b/platform-api/internal/repository/secret.go index 003d876f97..ba4ef8b939 100644 --- a/platform-api/internal/repository/secret.go +++ b/platform-api/internal/repository/secret.go @@ -42,7 +42,7 @@ func NewSecretRepo(db *database.DB) SecretRepository { } func (r *SecretRepo) Create(s *model.Secret) error { - now := time.Now() + now := time.Now().UTC() s.CreatedAt = now s.UpdatedAt = now @@ -202,7 +202,7 @@ func (r *SecretRepo) Count(orgID string) (int, error) { } func (r *SecretRepo) Update(s *model.Secret) error { - s.UpdatedAt = time.Now() + s.UpdatedAt = time.Now().UTC() query := r.db.Rebind(` UPDATE secrets @@ -294,7 +294,7 @@ func (r *SecretRepo) FindRefsAndSoftDelete(orgID, handle, updatedBy string) ([]m SET status = 'DEPRECATED', updated_at = ?, updated_by = ? WHERE organization_uuid = ? AND handle = ? `) - result, err := tx.Exec(deleteQuery, time.Now(), updatedBy, orgID, handle) + result, err := tx.Exec(deleteQuery, time.Now().UTC(), updatedBy, orgID, handle) if err != nil { return nil, fmt.Errorf("failed to deprecate secret: %w", err) } diff --git a/platform-api/internal/repository/secret_test.go b/platform-api/internal/repository/secret_test.go index d0cf07171b..f69831fb3d 100644 --- a/platform-api/internal/repository/secret_test.go +++ b/platform-api/internal/repository/secret_test.go @@ -292,7 +292,10 @@ func TestSecretRepo_List_UpdatedAfterFilter(t *testing.T) { t.Fatalf("Create: %v", err) } - future := time.Now().Add(time.Hour) + // updated_at is stored in UTC; the filter must also be UTC so the + // SQLite driver's text-based timestamp comparison stays chronologically + // correct (mixed offsets sort incorrectly as strings). + future := time.Now().UTC().Add(time.Hour) secrets, err := repo.List(orgID, 25, 0, &future) if err != nil { t.Fatalf("List with future filter: %v", err) @@ -301,7 +304,7 @@ func TestSecretRepo_List_UpdatedAfterFilter(t *testing.T) { t.Errorf("expected 0 results with future updatedAfter, got %d", len(secrets)) } - past := time.Now().Add(-time.Hour) + past := time.Now().UTC().Add(-time.Hour) secrets, err = repo.List(orgID, 25, 0, &past) if err != nil { t.Fatalf("List with past filter: %v", err) diff --git a/platform-api/internal/repository/subscription_plan_repository.go b/platform-api/internal/repository/subscription_plan_repository.go index 9e383d672e..4dc8070adb 100644 --- a/platform-api/internal/repository/subscription_plan_repository.go +++ b/platform-api/internal/repository/subscription_plan_repository.go @@ -65,7 +65,7 @@ func (r *SubscriptionPlanRepo) Create(plan *model.SubscriptionPlan) error { if plan.UUID == "" { plan.UUID = uuid.New().String() } - now := time.Now() + now := time.Now().UTC() plan.CreatedAt = now plan.UpdatedAt = now @@ -254,7 +254,7 @@ func (r *SubscriptionPlanRepo) Update(plan *model.SubscriptionPlan) error { if plan == nil { return fmt.Errorf("subscription plan is required") } - plan.UpdatedAt = time.Now() + plan.UpdatedAt = time.Now().UTC() tx, err := r.db.Begin() if err != nil { diff --git a/platform-api/internal/repository/subscription_repository.go b/platform-api/internal/repository/subscription_repository.go index e676810b27..9f5de319f4 100644 --- a/platform-api/internal/repository/subscription_repository.go +++ b/platform-api/internal/repository/subscription_repository.go @@ -102,7 +102,7 @@ func (r *SubscriptionRepo) Create(sub *model.Subscription) error { return fmt.Errorf("failed to encrypt subscription token: %w", err) } - now := time.Now() + now := time.Now().UTC() sub.CreatedAt = now sub.UpdatedAt = now @@ -294,7 +294,7 @@ func (r *SubscriptionRepo) CountByFilters(orgUUID string, apiUUID *string, subsc // Update updates an existing subscription with all mutable fields. func (r *SubscriptionRepo) Update(sub *model.Subscription) error { - sub.UpdatedAt = time.Now() + sub.UpdatedAt = time.Now().UTC() query := ` UPDATE subscriptions SET subscription_plan_uuid = ?, application_id = ?, status = ?, updated_by = ?, updated_at = ? @@ -337,7 +337,7 @@ func (r *SubscriptionRepo) UpdateToken(subscriptionID, orgUUID, newToken string) SET subscription_token = ?, subscription_token_hash = ?, updated_at = ? WHERE uuid = ? AND organization_uuid = ? ` - result, err := r.db.Exec(r.db.Rebind(query), encryptedToken, hashedToken, time.Now(), subscriptionID, orgUUID) + result, err := r.db.Exec(r.db.Rebind(query), encryptedToken, hashedToken, time.Now().UTC(), subscriptionID, orgUUID) if err != nil { if isSubscriptionUniqueViolation(err) { return apperror.SubscriptionExists.New() diff --git a/platform-api/internal/repository/user_identity_mapping.go b/platform-api/internal/repository/user_identity_mapping.go index 9a684aaca8..9a0deef43f 100644 --- a/platform-api/internal/repository/user_identity_mapping.go +++ b/platform-api/internal/repository/user_identity_mapping.go @@ -57,7 +57,7 @@ func (r *UserIdentityMappingRepo) GetOrCreateUUID(identity string) (string, erro newUUID := uuid.New().String() query := `INSERT INTO user_idp_references (uuid, idp_id, created_at) VALUES (?, ?, ?)` - _, err := r.db.Exec(r.db.Rebind(query), newUUID, identity, time.Now()) + _, err := r.db.Exec(r.db.Rebind(query), newUUID, identity, time.Now().UTC()) if err != nil { if isUniqueViolation(err) { // Lost the race to another concurrent request inserting the same idp_id. diff --git a/platform-api/internal/repository/user_organization_mapping.go b/platform-api/internal/repository/user_organization_mapping.go index 7395a769a2..94926e774a 100644 --- a/platform-api/internal/repository/user_organization_mapping.go +++ b/platform-api/internal/repository/user_organization_mapping.go @@ -38,7 +38,7 @@ func NewUserOrganizationMappingRepo(db *database.DB) UserOrganizationMappingRepo // duplicate (userUUID, orgUUID) pair is treated as success, not an error. func (r *UserOrganizationMappingRepo) AddMembership(userUUID, orgUUID string) error { query := `INSERT INTO user_organization_mappings (user_uuid, org_uuid, created_at) VALUES (?, ?, ?)` - _, err := r.db.Exec(r.db.Rebind(query), userUUID, orgUUID, time.Now()) + _, err := r.db.Exec(r.db.Rebind(query), userUUID, orgUUID, time.Now().UTC()) if err != nil { if isUniqueViolation(err) { return nil @@ -48,17 +48,19 @@ func (r *UserOrganizationMappingRepo) AddMembership(userUUID, orgUUID string) er return nil } -// DeleteByUser removes all membership rows for userUUID, within tx. Callers -// must run this before deleting the referenced user_idp_references row (no -// ON DELETE CASCADE on that FK by design). +// DeleteByUser removes all membership rows for userUUID, within tx. The FK to +// user_idp_references is declared ON DELETE CASCADE, so this is normally +// redundant; it exists as defense-in-depth for pooled SQLite connections that +// may not enforce foreign keys. func (r *UserOrganizationMappingRepo) DeleteByUser(tx *sql.Tx, userUUID string) error { _, err := tx.Exec(r.db.Rebind(`DELETE FROM user_organization_mappings WHERE user_uuid = ?`), userUUID) return err } -// DeleteByOrg removes all membership rows for orgUUID, within tx. Callers -// must run this before deleting the referenced organizations row (no -// ON DELETE CASCADE on that FK by design). +// DeleteByOrg removes all membership rows for orgUUID, within tx. The FK to +// organizations is declared ON DELETE CASCADE, so this is normally redundant; +// it exists as defense-in-depth for pooled SQLite connections that may not +// enforce foreign keys. func (r *UserOrganizationMappingRepo) DeleteByOrg(tx *sql.Tx, orgUUID string) error { _, err := tx.Exec(r.db.Rebind(`DELETE FROM user_organization_mappings WHERE org_uuid = ?`), orgUUID) return err diff --git a/platform-api/internal/repository/user_organization_mapping_test.go b/platform-api/internal/repository/user_organization_mapping_test.go new file mode 100644 index 0000000000..cd625dc9ed --- /dev/null +++ b/platform-api/internal/repository/user_organization_mapping_test.go @@ -0,0 +1,231 @@ +/* + * 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 ( + "testing" +) + +// createTestUser inserts a user_idp_references row and returns its UUID. +func createTestUser(t *testing.T, identityRepo UserIdentityMappingRepository, idpID string) string { + t.Helper() + uuid, err := identityRepo.GetOrCreateUUID(idpID) + if err != nil { + t.Fatalf("Failed to create test user %q: %v", idpID, err) + } + return uuid +} + +func TestUserOrganizationMappingRepo_AddMembership(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + identityRepo := NewUserIdentityMappingRepo(db) + mappingRepo := NewUserOrganizationMappingRepo(db) + + userUUID := createTestUser(t, identityRepo, "user-add-membership") + orgUUID := "org-add-membership" + createTestOrganizationAndProject(t, db, orgUUID, "project-add-membership") + + if err := mappingRepo.AddMembership(userUUID, orgUUID); err != nil { + t.Fatalf("AddMembership failed: %v", err) + } + + // Idempotent: a duplicate pair is a no-op, not an error. + if err := mappingRepo.AddMembership(userUUID, orgUUID); err != nil { + t.Fatalf("AddMembership (duplicate) should be a no-op, got error: %v", err) + } + + orgRepo := NewOrganizationRepo(db) + orgs, err := orgRepo.ListOrganizationsForUser(userUUID, 20, 0) + if err != nil { + t.Fatalf("ListOrganizationsForUser failed: %v", err) + } + if len(orgs) != 1 { + t.Fatalf("expected exactly 1 membership row despite duplicate AddMembership calls, got %d", len(orgs)) + } +} + +func TestUserOrganizationMappingRepo_AddMembership_UnknownUserOrOrgFails(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + identityRepo := NewUserIdentityMappingRepo(db) + mappingRepo := NewUserOrganizationMappingRepo(db) + + userUUID := createTestUser(t, identityRepo, "user-fk-check") + orgUUID := "org-fk-check" + createTestOrganizationAndProject(t, db, orgUUID, "project-fk-check") + + if err := mappingRepo.AddMembership("does-not-exist", orgUUID); err == nil { + t.Fatal("expected AddMembership to fail for an unknown user UUID (FK violation)") + } + if err := mappingRepo.AddMembership(userUUID, "does-not-exist"); err == nil { + t.Fatal("expected AddMembership to fail for an unknown org UUID (FK violation)") + } +} + +func TestOrganizationRepo_ListAndCountOrganizationsForUser(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + identityRepo := NewUserIdentityMappingRepo(db) + mappingRepo := NewUserOrganizationMappingRepo(db) + orgRepo := NewOrganizationRepo(db) + + userUUID := createTestUser(t, identityRepo, "user-list-orgs") + + // Three orgs; the user is a member of exactly two. + createTestOrganizationAndProject(t, db, "org-list-1", "project-list-1") + createTestOrganizationAndProject(t, db, "org-list-2", "project-list-2") + createTestOrganizationAndProject(t, db, "org-list-3", "project-list-3") + + if err := mappingRepo.AddMembership(userUUID, "org-list-1"); err != nil { + t.Fatalf("AddMembership failed: %v", err) + } + if err := mappingRepo.AddMembership(userUUID, "org-list-2"); err != nil { + t.Fatalf("AddMembership failed: %v", err) + } + + total, err := orgRepo.CountOrganizationsForUser(userUUID) + if err != nil { + t.Fatalf("CountOrganizationsForUser failed: %v", err) + } + if total != 2 { + t.Fatalf("expected count 2, got %d", total) + } + + orgs, err := orgRepo.ListOrganizationsForUser(userUUID, 20, 0) + if err != nil { + t.Fatalf("ListOrganizationsForUser failed: %v", err) + } + if len(orgs) != 2 { + t.Fatalf("expected 2 orgs, got %d", len(orgs)) + } + seen := map[string]bool{} + for _, org := range orgs { + seen[org.ID] = true + if org.ID == "org-list-3" { + t.Fatalf("org-list-3 should not appear for a user with no membership row for it") + } + } + if !seen["org-list-1"] || !seen["org-list-2"] { + t.Fatalf("expected both org-list-1 and org-list-2 in result, got %+v", orgs) + } + + // Pagination: limit=1 offset=1 should return exactly one of the two, newest first. + page, err := orgRepo.ListOrganizationsForUser(userUUID, 1, 1) + if err != nil { + t.Fatalf("ListOrganizationsForUser (paginated) failed: %v", err) + } + if len(page) != 1 { + t.Fatalf("expected 1 org on the second page, got %d", len(page)) + } + + // A user with no memberships sees nothing. + otherUser := createTestUser(t, identityRepo, "user-no-memberships") + none, err := orgRepo.ListOrganizationsForUser(otherUser, 20, 0) + if err != nil { + t.Fatalf("ListOrganizationsForUser (no memberships) failed: %v", err) + } + if len(none) != 0 { + t.Fatalf("expected 0 orgs for a user with no memberships, got %d", len(none)) + } + noneCount, err := orgRepo.CountOrganizationsForUser(otherUser) + if err != nil { + t.Fatalf("CountOrganizationsForUser (no memberships) failed: %v", err) + } + if noneCount != 0 { + t.Fatalf("expected count 0 for a user with no memberships, got %d", noneCount) + } +} + +func TestOrganizationRepo_DeleteOrganization_RemovesMembership(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + identityRepo := NewUserIdentityMappingRepo(db) + mappingRepo := NewUserOrganizationMappingRepo(db) + orgRepo := NewOrganizationRepo(db) + + userUUID := createTestUser(t, identityRepo, "user-delete-org") + orgUUID := "org-delete-me" + createTestOrganizationAndProject(t, db, orgUUID, "project-delete-me") + + if err := mappingRepo.AddMembership(userUUID, orgUUID); err != nil { + t.Fatalf("AddMembership failed: %v", err) + } + + if err := orgRepo.DeleteOrganization(orgUUID); err != nil { + t.Fatalf("DeleteOrganization failed: %v", err) + } + + orgs, err := orgRepo.ListOrganizationsForUser(userUUID, 20, 0) + if err != nil { + t.Fatalf("ListOrganizationsForUser after delete failed: %v", err) + } + if len(orgs) != 0 { + t.Fatalf("expected membership row to be removed after org deletion, got %d orgs", len(orgs)) + } +} + +func TestUserOrganizationMappingRepo_DeleteByUserAndByOrg(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + identityRepo := NewUserIdentityMappingRepo(db) + mappingRepo := NewUserOrganizationMappingRepo(db) + orgRepo := NewOrganizationRepo(db) + + userA := createTestUser(t, identityRepo, "user-delete-a") + userB := createTestUser(t, identityRepo, "user-delete-b") + createTestOrganizationAndProject(t, db, "org-delete-by-x", "project-delete-by-x") + + if err := mappingRepo.AddMembership(userA, "org-delete-by-x"); err != nil { + t.Fatalf("AddMembership failed: %v", err) + } + if err := mappingRepo.AddMembership(userB, "org-delete-by-x"); err != nil { + t.Fatalf("AddMembership failed: %v", err) + } + + tx, err := db.Begin() + if err != nil { + t.Fatalf("failed to begin tx: %v", err) + } + if err := mappingRepo.DeleteByUser(tx, userA); err != nil { + t.Fatalf("DeleteByUser failed: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("failed to commit tx: %v", err) + } + + remaining, err := orgRepo.CountOrganizationsForUser(userA) + if err != nil { + t.Fatalf("CountOrganizationsForUser failed: %v", err) + } + if remaining != 0 { + t.Fatalf("expected userA to have 0 memberships after DeleteByUser, got %d", remaining) + } + stillThere, err := orgRepo.CountOrganizationsForUser(userB) + if err != nil { + t.Fatalf("CountOrganizationsForUser failed: %v", err) + } + if stillThere != 1 { + t.Fatalf("expected userB's membership to be untouched, got %d", stillThere) + } +} diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index d8d59c0db6..39b49fb5a4 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -316,7 +316,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, secretService := service.NewSecretService(secretRepo, secretVault, identityService) // Initialize handlers - orgHandler := handler.NewOrganizationHandler(orgService, identityService, slogger) + orgHandler := handler.NewOrganizationHandler(orgService, identityService, cfg.Auth.IDP.ValidationMode, slogger) projectHandler := handler.NewProjectHandler(projectService, identityService, slogger) apiHandler := handler.NewAPIHandler(apiService, identityService, slogger) gatewayHandler := handler.NewGatewayHandler(gatewayService, identityService, slogger) @@ -328,11 +328,11 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService, identityService, slogger) deploymentHandler := handler.NewDeploymentHandler(deploymentService, identityService, slogger) llmHandler := handler.NewLLMHandler(llmTemplateService, llmProviderService, llmProxyService, identityService, slogger) - llmDeploymentHandler := handler.NewLLMProviderDeploymentHandler(llmProviderDeploymentService, slogger) + llmDeploymentHandler := handler.NewLLMProviderDeploymentHandler(llmProviderDeploymentService, identityService, slogger) llmProviderAPIKeyHandler := handler.NewLLMProviderAPIKeyHandler(llmProviderAPIKeyService, identityService, slogger) llmProxyAPIKeyHandler := handler.NewLLMProxyAPIKeyHandler(llmProxyAPIKeyService, identityService, slogger) apiKeyUserHandler := handler.NewAPIKeyUserHandler(apiKeyUserService, identityService, slogger) - llmProxyDeploymentHandler := handler.NewLLMProxyDeploymentHandler(llmProxyDeploymentService, slogger) + llmProxyDeploymentHandler := handler.NewLLMProxyDeploymentHandler(llmProxyDeploymentService, identityService, slogger) mcpProxyHandler := handler.NewMCPProxyHandler(mcpProxyService, identityService, slogger) mcpProxyDeploymentHandler := handler.NewMCPProxyDeploymentHandler(mcpDeploymentService, identityService, slogger) // Wire secret placeholder validation into dependent services diff --git a/platform-api/internal/service/api.go b/platform-api/internal/service/api.go index aee338c6f0..3c1e5248f6 100644 --- a/platform-api/internal/service/api.go +++ b/platform-api/internal/service/api.go @@ -25,7 +25,6 @@ import ( "regexp" "strconv" "strings" - "time" "github.com/wso2/api-platform/platform-api/api" "github.com/wso2/api-platform/platform-api/internal/apperror" @@ -163,6 +162,8 @@ func (s *APIService) CreateAPI(req *api.CreateRESTAPIRequest, orgUUID, createdBy apiREST := s.createRequestToRESTAPI(req, handle) apiModel := s.apiUtil.RESTAPIToModel(apiREST, orgUUID) apiModel.ProjectID = project.ID + // On creation the creator is also the last updater. + apiModel.UpdatedBy = createdBy // Create API in repository (UUID is generated internally by CreateAPI) if err := s.apiRepo.CreateAPI(apiModel); err != nil { s.slogger.Error("Failed to create API in repository", "apiName", req.DisplayName, "error", err) @@ -179,10 +180,13 @@ func (s *APIService) CreateAPI(req *api.CreateRESTAPIRequest, orgUUID, createdBy return s.modelToRESTAPI(apiModel) } -// modelToRESTAPI converts an internal API model to the API representation, -// resolving the project's handle for the response's projectId field and the -// createdBy/updatedBy UUIDs to their raw external identity. -func (s *APIService) modelToRESTAPI(apiModel *model.API) (*api.RESTAPI, error) { +// modelToRESTAPIUnresolved converts an internal API model to the API +// representation, resolving the project's handle for the response's +// projectId field, but leaving createdBy/updatedBy as raw internal UUIDs. +// Used by list endpoints, which batch-resolve identity across the whole page +// afterward instead of one-by-one — see modelToRESTAPI for the single-item +// equivalent that resolves inline. +func (s *APIService) modelToRESTAPIUnresolved(apiModel *model.API) (*api.RESTAPI, error) { if apiModel == nil { return nil, nil } @@ -194,9 +198,16 @@ func (s *APIService) modelToRESTAPI(apiModel *model.API) (*api.RESTAPI, error) { if project != nil { projectHandle = project.Handle } - resp, err := s.apiUtil.ModelToRESTAPI(apiModel, projectHandle) - if err != nil { - return nil, err + return s.apiUtil.ModelToRESTAPI(apiModel, projectHandle) +} + +// modelToRESTAPI converts an internal API model to the API representation, +// resolving the project's handle for the response's projectId field and the +// createdBy/updatedBy UUIDs to their raw external identity. +func (s *APIService) modelToRESTAPI(apiModel *model.API) (*api.RESTAPI, error) { + resp, err := s.modelToRESTAPIUnresolved(apiModel) + if err != nil || resp == nil { + return resp, err } if err := s.resolveRESTAPIIdentity(resp); err != nil { return nil, err @@ -290,9 +301,10 @@ func (s *APIService) GetAPIsByOrganization(orgUUID string, projectHandle string, return nil, 0, fmt.Errorf("failed to get apis: %w", err) } - apis := make([]api.RESTAPI, 0) + apis := make([]api.RESTAPI, 0, len(apiModels)) + createdByFields := make([]**string, 0, len(apiModels)) for _, apiModel := range apiModels { - apiResponse, err := s.modelToRESTAPI(apiModel) + apiResponse, err := s.modelToRESTAPIUnresolved(apiModel) if err != nil { return nil, 0, err } @@ -300,8 +312,12 @@ func (s *APIService) GetAPIsByOrganization(orgUUID string, projectHandle string, // updatedBy is detail-only; omit it from list responses. apiResponse.UpdatedBy = nil apis = append(apis, *apiResponse) + createdByFields = append(createdByFields, &apis[len(apis)-1].CreatedBy) } } + if err := s.identity.ResolveIdentityFields(createdByFields); err != nil { + return nil, 0, err + } return apis, total, nil } @@ -352,6 +368,7 @@ func (s *APIService) UpdateAPI(apiUUID string, req *api.RESTAPI, orgUUID, update updatedAPIModel.ProjectID = existingAPIModel.ProjectID updatedAPIModel.Kind = existingAPIModel.Kind updatedAPIModel.Origin = existingAPIModel.Origin + updatedAPIModel.CreatedBy = existingAPIModel.CreatedBy // A DP-originated (gateway_api) artifact is read-only in the control plane only for // changes that alter the gateway runtime artifact. Allow edits that leave the @@ -508,12 +525,12 @@ func (s *APIService) DeleteAPIByHandle(handle, orgId, deletedBy string) error { } // AddGatewaysToAPIByHandle associates multiple gateways with an API identified by handle -func (s *APIService) AddGatewaysToAPIByHandle(handle string, gatewayIds []string, orgId string) (*api.RESTAPIGatewayListResponse, error) { +func (s *APIService) AddGatewaysToAPIByHandle(handle string, gatewayIds []string, orgId, createdBy string) (*api.RESTAPIGatewayListResponse, error) { apiUUID, err := s.getAPIUUIDByHandle(handle, orgId) if err != nil { return nil, err } - return s.AddGatewaysToAPI(apiUUID, gatewayIds, orgId) + return s.AddGatewaysToAPI(apiUUID, gatewayIds, orgId, createdBy) } // GetAPIGatewaysByHandle retrieves a page of gateways associated with an API @@ -559,7 +576,7 @@ func (s *APIService) GetAPIGatewaysByHandle(handle, orgId string, limit, offset } // AddGatewaysToAPI associates multiple gateways with an API -func (s *APIService) AddGatewaysToAPI(apiUUID string, gatewayIds []string, orgUUID string) (*api.RESTAPIGatewayListResponse, error) { +func (s *APIService) AddGatewaysToAPI(apiUUID string, gatewayIds []string, orgUUID, createdBy string) (*api.RESTAPIGatewayListResponse, error) { apiModel, err := s.apiRepo.GetAPIByUUID(apiUUID, orgUUID) if err != nil { return nil, err @@ -591,7 +608,7 @@ func (s *APIService) AddGatewaysToAPI(apiUUID string, gatewayIds []string, orgUU } for _, gateway := range validGateways { if existingGatewayIds[gateway.ID] { - if err := s.apiRepo.UpdateAPIAssociation(apiUUID, gateway.ID, constants.AssociationTypeGateway, orgUUID); err != nil { + if err := s.apiRepo.UpdateAPIAssociation(apiUUID, gateway.ID, constants.AssociationTypeGateway, orgUUID, createdBy); err != nil { return nil, err } } else { @@ -599,8 +616,7 @@ func (s *APIService) AddGatewaysToAPI(apiUUID string, gatewayIds []string, orgUU ArtifactID: apiUUID, OrganizationID: orgUUID, GatewayID: gateway.ID, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + CreatedBy: createdBy, } if err := s.apiRepo.CreateAPIAssociation(association); err != nil { return nil, err diff --git a/platform-api/internal/service/apikey.go b/platform-api/internal/service/apikey.go index dd37434fcb..ccdcdc6e20 100644 --- a/platform-api/internal/service/apikey.go +++ b/platform-api/internal/service/apikey.go @@ -475,6 +475,7 @@ func (s *APIKeyService) CreateAPIKey(ctx context.Context, apiHandle, kind, orgId APIKeyHashes: apiKeyHashesJSON, Status: constants.APIKeyStatusActive, CreatedBy: userId, + UpdatedBy: userId, ExpiresAt: expiresAt, Issuer: issuer, AllowedTargets: allowedTargets, @@ -564,6 +565,7 @@ func (s *APIKeyService) UpdateAPIKey(ctx context.Context, apiHandle, kind, orgId MaskedAPIKey: maskedAPIKey, APIKeyHashes: apiKeyHashesJSON, Status: constants.APIKeyStatusActive, + UpdatedBy: userId, ExpiresAt: expiresAt, Issuer: req.Issuer, } @@ -654,7 +656,7 @@ func (s *APIKeyService) RevokeAPIKey(ctx context.Context, apiHandle, kind, orgId revokeKey, revokeKeyErr := s.apiKeyRepo.GetByArtifactAndName(apiId, keyName) // Revoke the API key in the database before broadcasting - if err := s.apiKeyRepo.Revoke(apiId, keyName); err != nil { + if err := s.apiKeyRepo.Revoke(apiId, keyName, userId); err != nil { s.slogger.Error("Failed to revoke API key in database", "apiHandle", apiHandle, "keyName", keyName, "error", err) return fmt.Errorf("failed to revoke API key in database: %w", err) } diff --git a/platform-api/internal/service/apikey_user.go b/platform-api/internal/service/apikey_user.go index 752459182d..31e972cd6f 100644 --- a/platform-api/internal/service/apikey_user.go +++ b/platform-api/internal/service/apikey_user.go @@ -62,18 +62,15 @@ func (s *APIKeyUserService) ListAPIKeysByUser( } items := make([]api.UserAPIKeyItem, 0, len(keys)) + createdByFields := make([]**string, 0, len(keys)) for _, k := range keys { - createdBy := utils.StringPtrIfNotEmpty(k.CreatedBy) - if err := s.identity.ResolveIdentityField(&createdBy); err != nil { - return nil, err - } item := api.UserAPIKeyItem{ Id: &k.Name, DisplayName: k.DisplayName, MaskedApiKey: k.MaskedAPIKey, Status: api.UserAPIKeyItemStatus(k.Status), CreatedAt: k.CreatedAt, - CreatedBy: createdBy, + CreatedBy: utils.StringPtrIfNotEmpty(k.CreatedBy), UpdatedAt: k.UpdatedAt, ExpiresAt: k.ExpiresAt, Issuer: k.Issuer, @@ -82,6 +79,10 @@ func (s *APIKeyUserService) ListAPIKeysByUser( ArtifactType: api.UserAPIKeyItemArtifactType(k.ArtifactType), } items = append(items, item) + createdByFields = append(createdByFields, &items[len(items)-1].CreatedBy) + } + if err := s.identity.ResolveIdentityFields(createdByFields); err != nil { + return nil, err } // A user's API keys are a small, bounded set, so the total is the full count diff --git a/platform-api/internal/service/application.go b/platform-api/internal/service/application.go index 95c0b470c8..871f494a2b 100644 --- a/platform-api/internal/service/application.go +++ b/platform-api/internal/service/application.go @@ -233,8 +233,9 @@ func (s *ApplicationService) GetApplicationsByOrganization(orgID, projectHandle }, } + createdByFields := make([]**string, 0, len(pagedApps)) for _, app := range pagedApps { - mapped, err := s.modelToApplicationResponse(app) + mapped, err := s.modelToApplicationResponseUnresolved(app) if err != nil { return nil, err } @@ -242,8 +243,12 @@ func (s *ApplicationService) GetApplicationsByOrganization(orgID, projectHandle // updatedBy is detail-only; omit it from list responses. mapped.UpdatedBy = nil response.List = append(response.List, *mapped) + createdByFields = append(createdByFields, &response.List[len(response.List)-1].CreatedBy) } } + if err := s.identity.ResolveIdentityFields(createdByFields); err != nil { + return nil, err + } return response, nil } @@ -888,7 +893,13 @@ func (s *ApplicationService) buildMappedAPIKeyResponse(keys []*model.Application return response, nil } -func (s *ApplicationService) modelToApplicationResponse(app *model.Application) (*api.Application, error) { +// modelToApplicationResponseUnresolved converts app to its API representation, +// resolving the project handle, but leaving createdBy/updatedBy as raw +// internal UUIDs. Used by list endpoints, which batch-resolve identity across +// the whole page afterward instead of one-by-one — see +// modelToApplicationResponse for the single-item equivalent that resolves +// inline. +func (s *ApplicationService) modelToApplicationResponseUnresolved(app *model.Application) (*api.Application, error) { if app == nil { return nil, nil } @@ -906,7 +917,7 @@ func (s *ApplicationService) modelToApplicationResponse(app *model.Application) projectHandle = project.Handle } - resp := &api.Application{ + return &api.Application{ Id: app.Handle, DisplayName: app.Name, ProjectId: projectHandle, @@ -916,6 +927,13 @@ func (s *ApplicationService) modelToApplicationResponse(app *model.Application) UpdatedBy: utils.StringPtrIfNotEmpty(app.UpdatedBy), CreatedAt: utils.TimePtrIfNotZero(app.CreatedAt), UpdatedAt: utils.TimePtrIfNotZero(app.UpdatedAt), + }, nil +} + +func (s *ApplicationService) modelToApplicationResponse(app *model.Application) (*api.Application, error) { + resp, err := s.modelToApplicationResponseUnresolved(app) + if err != nil || resp == nil { + return resp, err } if err := s.identity.ResolveIdentityField(&resp.CreatedBy); err != nil { return nil, err diff --git a/platform-api/internal/service/artifact_import.go b/platform-api/internal/service/artifact_import.go index 7392b33741..bd62759d7d 100644 --- a/platform-api/internal/service/artifact_import.go +++ b/platform-api/internal/service/artifact_import.go @@ -403,13 +403,10 @@ func (s *ArtifactImportService) writeDeployment(ictx *ImportContext, artifactUUI } } if !associated { - now := time.Now() if err := s.apiRepo.CreateAPIAssociation(&model.APIAssociation{ ArtifactID: artifactUUID, OrganizationID: ictx.OrgID, GatewayID: ictx.GatewayID, - CreatedAt: now, - UpdatedAt: now, }); err != nil { return fmt.Errorf("failed to create artifact-gateway association: %w", err) } diff --git a/platform-api/internal/service/deployment.go b/platform-api/internal/service/deployment.go index c56ff60e87..a67b223cb9 100644 --- a/platform-api/internal/service/deployment.go +++ b/platform-api/internal/service/deployment.go @@ -321,7 +321,7 @@ func (s *DeploymentService) DeployAPI(apiUUID string, req *api.DeployRequest, or } // Ensure API-Gateway association exists - if err := s.ensureAPIGatewayAssociation(apiUUID, gatewayID, orgUUID); err != nil { + if err := s.ensureAPIGatewayAssociation(apiUUID, gatewayID, orgUUID, createdBy); err != nil { s.slogger.Warn("Failed to ensure API-gateway association", "error", err) } @@ -330,7 +330,7 @@ func (s *DeploymentService) DeployAPI(apiUUID string, req *api.DeployRequest, or if s.cfg.Deployments.TransitionalStatusEnabled { initialStatus = model.DeploymentStatusDeploying } - performedAt := time.Now().Truncate(time.Millisecond) + performedAt := time.Now().UTC().Truncate(time.Millisecond) if _, err := s.deploymentRepo.SetCurrentWithDetails( apiUUID, orgUUID, gatewayID, deploymentID, initialStatus, string(model.DeploymentStatusDeployed), @@ -416,7 +416,7 @@ func (s *DeploymentService) RestoreDeployment(apiUUID, deploymentID, gatewayID, if s.cfg.Deployments.TransitionalStatusEnabled { initialStatus = model.DeploymentStatusDeploying } - performedAt := time.Now().Truncate(time.Millisecond) + performedAt := time.Now().UTC().Truncate(time.Millisecond) updatedAt, err := s.deploymentRepo.SetCurrentWithDetails( apiUUID, orgUUID, targetDeployment.GatewayID, deploymentID, initialStatus, string(model.DeploymentStatusDeployed), @@ -503,7 +503,7 @@ func (s *DeploymentService) UndeployDeployment(apiUUID, deploymentID, gatewayID, if s.cfg.Deployments.TransitionalStatusEnabled { initialStatus = model.DeploymentStatusUndeploying } - performedAt := time.Now().Truncate(time.Millisecond) + performedAt := time.Now().UTC().Truncate(time.Millisecond) newUpdatedAt, err := s.deploymentRepo.SetCurrentWithDetails( apiUUID, orgUUID, deployment.GatewayID, deploymentID, initialStatus, string(model.DeploymentStatusUndeployed), @@ -867,7 +867,7 @@ func (s *DeploymentService) GetDeploymentContent(apiUUID, deploymentID, orgUUID } // ensureAPIGatewayAssociation ensures an association exists between API and gateway -func (s *DeploymentService) ensureAPIGatewayAssociation(apiUUID, gatewayID, orgUUID string) error { +func (s *DeploymentService) ensureAPIGatewayAssociation(apiUUID, gatewayID, orgUUID, createdBy string) error { // Check if association already exists associations, err := s.apiRepo.GetAPIAssociations(apiUUID, constants.AssociationTypeGateway, orgUUID) if err != nil { @@ -886,8 +886,7 @@ func (s *DeploymentService) ensureAPIGatewayAssociation(apiUUID, gatewayID, orgU ArtifactID: apiUUID, OrganizationID: orgUUID, GatewayID: gatewayID, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + CreatedBy: createdBy, } return s.apiRepo.CreateAPIAssociation(association) diff --git a/platform-api/internal/service/deployment_test.go b/platform-api/internal/service/deployment_test.go index d105d98d53..2b73f3686f 100644 --- a/platform-api/internal/service/deployment_test.go +++ b/platform-api/internal/service/deployment_test.go @@ -270,11 +270,12 @@ type mockDeploymentRepo struct { createWithLimitError error // Call tracking - setCurrentCalled bool - setCurrentArtifactID string - setCurrentGatewayID string - setCurrentStatus model.DeploymentStatus - deleteCalled bool + setCurrentCalled bool + setCurrentArtifactID string + setCurrentGatewayID string + setCurrentStatus model.DeploymentStatus + setCurrentPerformedAt *time.Time + deleteCalled bool } func (m *mockDeploymentRepo) GetWithContent(deploymentID, artifactUUID, orgUUID string) (*model.Deployment, error) { @@ -321,6 +322,7 @@ func (m *mockDeploymentRepo) SetCurrentWithDetails(artifactUUID, orgUUID, gatewa m.setCurrentArtifactID = artifactUUID m.setCurrentGatewayID = gatewayID m.setCurrentStatus = status + m.setCurrentPerformedAt = performedAt if m.setCurrentError != nil { return time.Time{}, m.setCurrentError } @@ -614,6 +616,56 @@ func TestRestoreDeployment(t *testing.T) { } } +// TestRestoreDeployment_PerformedAtIsUTC guards against a prior gap where +// performedAt was computed with local-server-time time.Now() instead of +// time.Now().UTC(), leaving deployment_status.performed_at in a different +// timezone convention than the sibling updated_at column (which the +// repository always computes as UTC). +func TestRestoreDeployment_PerformedAtIsUTC(t *testing.T) { + testOrgUUID := "00000000-0000-0000-0000-000000000123" + testAPIUUID := "11111111-1111-1111-1111-111111111111" + testGatewayID := "22222222-2222-2222-2222-222222222222" + testDeploymentID := "33333333-3333-3333-3333-333333333333" + + mockAPIRepo := &mockDeploymentAPIRepository{} + mockDeploymentRepo := &mockDeploymentRepo{ + deploymentWithContent: &model.Deployment{ + DeploymentID: testDeploymentID, + Name: "test-deployment", + ArtifactID: testAPIUUID, + GatewayID: testGatewayID, + Content: []byte("test content"), + }, + currentDeploymentID: "44444444-4444-4444-4444-444444444444", + currentStatus: model.DeploymentStatusUndeployed, + } + mockGatewayRepo := &mockDeploymentGatewayRepository{ + gateway: &model.Gateway{ + ID: testGatewayID, + OrganizationID: testOrgUUID, + Endpoints: []string{"https://api.example.com"}, + }, + } + + service := &DeploymentService{ + apiRepo: mockAPIRepo, + deploymentRepo: mockDeploymentRepo, + gatewayRepo: mockGatewayRepo, + cfg: &config.Server{}, + } + + if _, err := service.RestoreDeployment(testAPIUUID, testDeploymentID, testGatewayID, testOrgUUID, "actor"); err != nil { + t.Fatalf("RestoreDeployment failed: %v", err) + } + + if mockDeploymentRepo.setCurrentPerformedAt == nil { + t.Fatal("expected SetCurrentWithDetails to be called with a non-nil performedAt") + } + if mockDeploymentRepo.setCurrentPerformedAt.Location() != time.UTC { + t.Fatalf("expected performedAt to be computed in UTC, got location %v", mockDeploymentRepo.setCurrentPerformedAt.Location()) + } +} + // ============================================================================ // DeploymentService.UndeployDeployment Tests // ============================================================================ diff --git a/platform-api/internal/service/gateway.go b/platform-api/internal/service/gateway.go index 4e9a7dfd14..ec88794a92 100644 --- a/platform-api/internal/service/gateway.go +++ b/platform-api/internal/service/gateway.go @@ -673,8 +673,9 @@ func (s *GatewayService) ListGateways(orgID *string, opts repository.ListOptions // Convert to API types responses := make([]api.GatewayResponse, 0, len(gateways)) + createdByFields := make([]**string, 0, len(gateways)) for _, gw := range gateways { - resp, err := s.gatewayModelToAPI(gw) + resp, err := s.gatewayModelToAPIUnresolved(gw) if err != nil { return nil, err } @@ -682,8 +683,12 @@ func (s *GatewayService) ListGateways(orgID *string, opts repository.ListOptions // updatedBy is detail-only; omit it from list responses. resp.UpdatedBy = nil responses = append(responses, *resp) + createdByFields = append(createdByFields, &responses[len(responses)-1].CreatedBy) } } + if err := s.identity.ResolveIdentityFields(createdByFields); err != nil { + return nil, err + } // Build constitution-compliant list response with pagination metadata return &api.GatewayListResponse{ @@ -736,7 +741,6 @@ func (s *GatewayService) UpdateGateway(gatewayId, orgId, updatedBy string, req * gateway.Properties = *req.Properties } gateway.UpdatedBy = updatedBy - gateway.UpdatedAt = time.Now() err = s.gatewayRepo.UpdateGateway(gateway) if err != nil { @@ -1078,7 +1082,12 @@ func hashToken(plainToken string) string { // Mapping functions // gatewayModelToAPI converts a Gateway model to GatewayResponse API type -func (s *GatewayService) gatewayModelToAPI(gateway *model.Gateway) (*api.GatewayResponse, error) { +// gatewayModelToAPIUnresolved converts gateway to its API representation, +// resolving the organization handle, but leaving createdBy/updatedBy as raw +// internal UUIDs. Used by list endpoints, which batch-resolve identity across +// the whole page afterward instead of one-by-one — see gatewayModelToAPI for +// the single-item equivalent that resolves inline. +func (s *GatewayService) gatewayModelToAPIUnresolved(gateway *model.Gateway) (*api.GatewayResponse, error) { if gateway == nil { return nil, nil } @@ -1089,7 +1098,7 @@ func (s *GatewayService) gatewayModelToAPI(gateway *model.Gateway) (*api.Gateway } functionalityType := api.GatewayResponseFunctionalityType(gateway.FunctionalityType) - resp := &api.GatewayResponse{ + return &api.GatewayResponse{ Id: &gateway.Handle, OrganizationId: &orgHandle, DisplayName: gateway.Name, @@ -1104,6 +1113,13 @@ func (s *GatewayService) gatewayModelToAPI(gateway *model.Gateway) (*api.Gateway UpdatedBy: utils.StringPtrIfNotEmpty(gateway.UpdatedBy), CreatedAt: &gateway.CreatedAt, UpdatedAt: &gateway.UpdatedAt, + }, nil +} + +func (s *GatewayService) gatewayModelToAPI(gateway *model.Gateway) (*api.GatewayResponse, error) { + resp, err := s.gatewayModelToAPIUnresolved(gateway) + if err != nil || resp == nil { + return resp, err } if err := s.identity.ResolveIdentityField(&resp.CreatedBy); err != nil { return nil, err diff --git a/platform-api/internal/service/identity_deleted_user_test.go b/platform-api/internal/service/identity_deleted_user_test.go new file mode 100644 index 0000000000..29b9e770e9 --- /dev/null +++ b/platform-api/internal/service/identity_deleted_user_test.go @@ -0,0 +1,419 @@ +/* + * 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. + * + */ + +// Tests the "forceful user removal" flow described in the audit-fields +// follow-up to #2371/#2370: when the user_idp_references row backing a +// created_by/updated_by UUID is deleted (or the UUID is repointed), audit +// identity fields must resolve to constants.DeletedUser instead of leaking +// the raw internal UUID or erroring. + +package service + +import ( + "database/sql" + "os" + "path/filepath" + "testing" + + "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/database" + "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/mattn/go-sqlite3" +) + +// setupIdentityTestDB creates a real SQLite-backed DB plus a real +// IdentityService for tests that need genuine sub<->UUID mapping rows +// (as opposed to the passthroughIdentityRepo used elsewhere in this package). +func setupIdentityTestDB(t *testing.T) (*database.DB, *IdentityService, func()) { + t.Helper() + + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "identity-test.db") + sqlDB, err := sql.Open("sqlite3", dbPath) + if err != nil { + t.Fatalf("open db: %v", err) + } + if _, err := sqlDB.Exec("PRAGMA foreign_keys = ON"); err != nil { + t.Fatalf("enable foreign keys: %v", err) + } + db := &database.DB{DB: sqlDB} + + schemaPath := filepath.Join("..", "database", "schema.sqlite.sql") + schema, err := os.ReadFile(schemaPath) + if err != nil { + t.Fatalf("read schema: %v", err) + } + if _, err := db.Exec(string(schema)); err != nil { + t.Fatalf("apply schema: %v", err) + } + + identity := NewIdentityService(repository.NewUserIdentityMappingRepo(db)) + cleanup := func() { sqlDB.Close() } + return db, identity, cleanup +} + +// TestIdentityService_SubForUUID_DeletedUser mimics forcefully removing a +// user: mint a real UUID for "sub-a", store it as an artifact's created_by, +// delete the user_idp_references row, and confirm resolution falls back to +// constants.DeletedUser rather than erroring or returning the raw UUID. +func TestIdentityService_SubForUUID_DeletedUser(t *testing.T) { + db, identity, cleanup := setupIdentityTestDB(t) + t.Cleanup(cleanup) + + userUUID, err := identity.ToInternalUUID("sub-a") + if err != nil { + t.Fatalf("ToInternalUUID failed: %v", err) + } + + resolved, err := identity.SubForUUID(userUUID) + if err != nil { + t.Fatalf("SubForUUID (before delete) failed: %v", err) + } + if resolved != "sub-a" { + t.Fatalf("expected resolution to sub-a before delete, got %q", resolved) + } + + if _, err := db.Exec(`DELETE FROM user_idp_references WHERE idp_id = 'sub-a'`); err != nil { + t.Fatalf("failed to delete user_idp_references row: %v", err) + } + + resolved, err = identity.SubForUUID(userUUID) + if err != nil { + t.Fatalf("SubForUUID (after delete) failed: %v", err) + } + if resolved != constants.DeletedUser { + t.Fatalf("expected resolution to fall back to %q after the mapping row is deleted, got %q", constants.DeletedUser, resolved) + } +} + +// TestIdentityService_SubsForUUIDs_DeletedUser exercises the batch resolver +// used by list responses: a mix of a still-mapped UUID, a UUID whose mapping +// was deleted, and an unmapped/anonymous UUID that never had a row. +func TestIdentityService_SubsForUUIDs_DeletedUser(t *testing.T) { + db, identity, cleanup := setupIdentityTestDB(t) + t.Cleanup(cleanup) + + aliveUUID, err := identity.ToInternalUUID("sub-alive") + if err != nil { + t.Fatalf("ToInternalUUID(sub-alive) failed: %v", err) + } + removedUUID, err := identity.ToInternalUUID("sub-removed") + if err != nil { + t.Fatalf("ToInternalUUID(sub-removed) failed: %v", err) + } + if _, err := db.Exec(`DELETE FROM user_idp_references WHERE idp_id = 'sub-removed'`); err != nil { + t.Fatalf("failed to delete user_idp_references row: %v", err) + } + neverMappedUUID := "00000000-0000-0000-0000-000000000000" + + resolved, err := identity.SubsForUUIDs([]string{aliveUUID, removedUUID, neverMappedUUID}) + if err != nil { + t.Fatalf("SubsForUUIDs failed: %v", err) + } + if resolved[aliveUUID] != "sub-alive" { + t.Fatalf("expected %q to resolve to sub-alive, got %q", aliveUUID, resolved[aliveUUID]) + } + if resolved[removedUUID] != constants.DeletedUser { + t.Fatalf("expected removed mapping to resolve to %q, got %q", constants.DeletedUser, resolved[removedUUID]) + } + if resolved[neverMappedUUID] != constants.DeletedUser { + t.Fatalf("expected never-mapped UUID to resolve to %q, got %q", constants.DeletedUser, resolved[neverMappedUUID]) + } +} + +// TestAPIService_ModelToRESTAPI_DeletedUser exercises the actual response +// path a REST API's detail/list response takes: create an API whose +// created_by/updated_by are a real internal UUID, delete the backing +// user_idp_references row (the "forcefully remove an existing user" flow), +// and confirm the API's response fields resolve to constants.DeletedUser +// instead of leaking the raw UUID. +func TestAPIService_ModelToRESTAPI_DeletedUser(t *testing.T) { + db, identity, cleanup := setupIdentityTestDB(t) + t.Cleanup(cleanup) + + orgUUID := "org-deleted-user" + projectUUID := "project-deleted-user" + if _, err := db.Exec(`INSERT INTO organizations (uuid, handle, display_name, region, idp_organization_ref_uuid, created_at, updated_at) + VALUES (?, 'deleted-user-org', 'Deleted User Org', 'default', 'idp-ref', datetime('now'), datetime('now'))`, orgUUID); err != nil { + t.Fatalf("insert org: %v", err) + } + if _, err := db.Exec(`INSERT INTO projects (uuid, handle, display_name, organization_uuid, description, created_at, updated_at) + VALUES (?, 'deleted-user-project', 'Deleted User Project', ?, '', datetime('now'), datetime('now'))`, projectUUID, orgUUID); err != nil { + t.Fatalf("insert project: %v", err) + } + + actorUUID, err := identity.ToInternalUUID("sub-to-delete") + if err != nil { + t.Fatalf("ToInternalUUID failed: %v", err) + } + + apiRepo := repository.NewAPIRepo(db) + apiModel := &model.API{ + Handle: "deleted-user-api", + Name: "Deleted User API", + Version: "1.0.0", + CreatedBy: actorUUID, + UpdatedBy: actorUUID, + ProjectID: projectUUID, + OrganizationID: orgUUID, + LifeCycleStatus: "CREATED", + Configuration: model.RestAPIConfig{ + Name: "Deleted User API", + Version: "1.0.0", + Transport: []string{"https"}, + }, + } + if err := apiRepo.CreateAPI(apiModel); err != nil { + t.Fatalf("CreateAPI failed: %v", err) + } + + apiSvc := &APIService{ + apiRepo: apiRepo, + projectRepo: repository.NewProjectRepo(db), + apiUtil: &utils.APIUtil{}, + identity: identity, + } + + before, err := apiSvc.modelToRESTAPI(apiModel) + if err != nil { + t.Fatalf("modelToRESTAPI (before delete) failed: %v", err) + } + if before.CreatedBy == nil || *before.CreatedBy != "sub-to-delete" { + t.Fatalf("expected createdBy to resolve to sub-to-delete before delete, got %v", before.CreatedBy) + } + if before.UpdatedBy == nil || *before.UpdatedBy != "sub-to-delete" { + t.Fatalf("expected updatedBy to resolve to sub-to-delete before delete, got %v", before.UpdatedBy) + } + + // Mimic forcefully removing the user: delete their user_idp_references row. + if _, err := db.Exec(`DELETE FROM user_idp_references WHERE idp_id = 'sub-to-delete'`); err != nil { + t.Fatalf("failed to delete user_idp_references row: %v", err) + } + + // Re-fetch from the DB (fresh model instance) rather than reusing apiModel, + // so this exercises the same read path a real GET request would take. + reread, err := apiRepo.GetAPIByUUID(apiModel.ID, orgUUID) + if err != nil { + t.Fatalf("GetAPIByUUID failed: %v", err) + } + if reread == nil { + t.Fatal("GetAPIByUUID returned nil") + } + + after, err := apiSvc.modelToRESTAPI(reread) + if err != nil { + t.Fatalf("modelToRESTAPI (after delete) failed: %v", err) + } + if after.CreatedBy == nil || *after.CreatedBy != constants.DeletedUser { + t.Fatalf("expected createdBy to resolve to %q after the user is removed, got %v", constants.DeletedUser, after.CreatedBy) + } + if after.UpdatedBy == nil || *after.UpdatedBy != constants.DeletedUser { + t.Fatalf("expected updatedBy to resolve to %q after the user is removed, got %v", constants.DeletedUser, after.UpdatedBy) + } +} + +// TestAPIService_GetAPIsByOrganization_BatchResolvesDeletedUser exercises the +// list-endpoint identity resolution path (batched via +// IdentityService.ResolveIdentityFields, not the per-item ResolveIdentityField +// used by detail responses) with a mix of an alive user and a removed one, +// confirming each list item resolves independently and correctly. +func TestAPIService_GetAPIsByOrganization_BatchResolvesDeletedUser(t *testing.T) { + db, identity, cleanup := setupIdentityTestDB(t) + t.Cleanup(cleanup) + + orgUUID := "org-list-deleted-user" + projectUUID := "project-list-deleted-user" + if _, err := db.Exec(`INSERT INTO organizations (uuid, handle, display_name, region, idp_organization_ref_uuid, created_at, updated_at) + VALUES (?, 'list-deleted-user-org', 'List Deleted User Org', 'default', 'idp-ref', datetime('now'), datetime('now'))`, orgUUID); err != nil { + t.Fatalf("insert org: %v", err) + } + if _, err := db.Exec(`INSERT INTO projects (uuid, handle, display_name, organization_uuid, description, created_at, updated_at) + VALUES (?, 'list-deleted-user-project', 'List Deleted User Project', ?, '', datetime('now'), datetime('now'))`, projectUUID, orgUUID); err != nil { + t.Fatalf("insert project: %v", err) + } + + aliveUUID, err := identity.ToInternalUUID("sub-list-alive") + if err != nil { + t.Fatalf("ToInternalUUID(sub-list-alive) failed: %v", err) + } + removedUUID, err := identity.ToInternalUUID("sub-list-removed") + if err != nil { + t.Fatalf("ToInternalUUID(sub-list-removed) failed: %v", err) + } + + apiRepo := repository.NewAPIRepo(db) + for _, tc := range []struct { + handle string + createdBy string + }{ + {"list-deleted-user-api-alive", aliveUUID}, + {"list-deleted-user-api-removed", removedUUID}, + } { + m := &model.API{ + Handle: tc.handle, + Name: tc.handle, + Version: "1.0.0", + CreatedBy: tc.createdBy, + UpdatedBy: tc.createdBy, + ProjectID: projectUUID, + OrganizationID: orgUUID, + LifeCycleStatus: "CREATED", + Configuration: model.RestAPIConfig{ + Name: tc.handle, + Version: "1.0.0", + Transport: []string{"https"}, + }, + } + if err := apiRepo.CreateAPI(m); err != nil { + t.Fatalf("CreateAPI(%s) failed: %v", tc.handle, err) + } + } + + if _, err := db.Exec(`DELETE FROM user_idp_references WHERE idp_id = 'sub-list-removed'`); err != nil { + t.Fatalf("failed to delete user_idp_references row: %v", err) + } + + apiSvc := &APIService{ + apiRepo: apiRepo, + projectRepo: repository.NewProjectRepo(db), + apiUtil: &utils.APIUtil{}, + identity: identity, + } + + list, _, err := apiSvc.GetAPIsByOrganization(orgUUID, "", repository.ListOptions{Limit: 100}) + if err != nil { + t.Fatalf("GetAPIsByOrganization failed: %v", err) + } + if len(list) != 2 { + t.Fatalf("expected 2 APIs, got %d", len(list)) + } + + byHandle := make(map[string]*string, len(list)) + for i := range list { + byHandle[*list[i].Id] = list[i].CreatedBy + } + if createdBy := byHandle["list-deleted-user-api-alive"]; createdBy == nil || *createdBy != "sub-list-alive" { + t.Fatalf("expected the alive user's API to resolve to sub-list-alive, got %v", createdBy) + } + if createdBy := byHandle["list-deleted-user-api-removed"]; createdBy == nil || *createdBy != constants.DeletedUser { + t.Fatalf("expected the removed user's API to resolve to %q, got %v", constants.DeletedUser, createdBy) + } +} + +// TestAPIService_UpdateAPI_PreservesCreatedByAcrossDifferentActor guards a +// regression in UpdateAPI: its response resolved createdBy to +// constants.DeletedUser whenever the update was performed by an actor +// different from the creator, even though the persisted created_by column +// was never touched and a plain GET after the same update resolved +// correctly. Root cause: applyAPIUpdates builds its return DTO from +// modelToRESTAPI(existingAPIModel), which already resolves CreatedBy from a +// raw UUID to a raw username (e.g. "sub-creator"); RESTAPIToModel then fed +// that already-resolved username back in as if it were the internal UUID, +// and the response's second identity-resolution pass found no +// user_idp_references row named "sub-creator", falling back to +// constants.DeletedUser. Fixed by re-deriving CreatedBy from +// existingAPIModel (the raw UUID) immediately before the final response +// conversion. +func TestAPIService_UpdateAPI_PreservesCreatedByAcrossDifferentActor(t *testing.T) { + db, identity, cleanup := setupIdentityTestDB(t) + t.Cleanup(cleanup) + + orgUUID := "org-update-actor" + projectUUID := "project-update-actor" + if _, err := db.Exec(`INSERT INTO organizations (uuid, handle, display_name, region, idp_organization_ref_uuid, created_at, updated_at) + VALUES (?, 'update-actor-org', 'Update Actor Org', 'default', 'idp-ref', datetime('now'), datetime('now'))`, orgUUID); err != nil { + t.Fatalf("insert org: %v", err) + } + if _, err := db.Exec(`INSERT INTO projects (uuid, handle, display_name, organization_uuid, description, created_at, updated_at) + VALUES (?, 'update-actor-project', 'Update Actor Project', ?, '', datetime('now'), datetime('now'))`, projectUUID, orgUUID); err != nil { + t.Fatalf("insert project: %v", err) + } + + creatorUUID, err := identity.ToInternalUUID("sub-creator") + if err != nil { + t.Fatalf("ToInternalUUID(creator) failed: %v", err) + } + updaterUUID, err := identity.ToInternalUUID("sub-updater") + if err != nil { + t.Fatalf("ToInternalUUID(updater) failed: %v", err) + } + + apiRepo := repository.NewAPIRepo(db) + apiModel := &model.API{ + Handle: "update-actor-api", + Name: "Update Actor API", + Version: "1.0.0", + CreatedBy: creatorUUID, + UpdatedBy: creatorUUID, + ProjectID: projectUUID, + OrganizationID: orgUUID, + LifeCycleStatus: "CREATED", + Configuration: model.RestAPIConfig{ + Name: "Update Actor API", + Version: "1.0.0", + Transport: []string{"https"}, + }, + } + if err := apiRepo.CreateAPI(apiModel); err != nil { + t.Fatalf("CreateAPI failed: %v", err) + } + + apiSvc := &APIService{ + apiRepo: apiRepo, + projectRepo: repository.NewProjectRepo(db), + apiUtil: &utils.APIUtil{}, + identity: identity, + auditRepo: &noopAuditRepo{}, + } + + updateReq := &api.RESTAPI{ + DisplayName: "Update Actor API (renamed)", + Version: "1.0.0", + Upstream: api.Upstream{ + Main: api.UpstreamDefinition{Url: ptr("https://example.com")}, + }, + } + updated, err := apiSvc.UpdateAPI(apiModel.ID, updateReq, orgUUID, updaterUUID) + if err != nil { + t.Fatalf("UpdateAPI failed: %v", err) + } + if updated.CreatedBy == nil || *updated.CreatedBy != "sub-creator" { + t.Fatalf("expected createdBy to remain resolved to the original creator (sub-creator), got %v", updated.CreatedBy) + } + if updated.UpdatedBy == nil || *updated.UpdatedBy != "sub-updater" { + t.Fatalf("expected updatedBy to resolve to the new actor (sub-updater), got %v", updated.UpdatedBy) + } + + // A subsequent GET must resolve identically — confirms the fix isn't + // merely masking the bug in one response while leaving stored data (or + // other read paths) inconsistent. + reread, err := apiRepo.GetAPIByUUID(apiModel.ID, orgUUID) + if err != nil { + t.Fatalf("GetAPIByUUID failed: %v", err) + } + reGet, err := apiSvc.modelToRESTAPI(reread) + if err != nil { + t.Fatalf("modelToRESTAPI failed: %v", err) + } + if reGet.CreatedBy == nil || *reGet.CreatedBy != "sub-creator" { + t.Fatalf("expected a subsequent GET to also resolve createdBy to sub-creator, got %v", reGet.CreatedBy) + } +} diff --git a/platform-api/internal/service/llm.go b/platform-api/internal/service/llm.go index e2d4bed346..22b03e78bc 100644 --- a/platform-api/internal/service/llm.go +++ b/platform-api/internal/service/llm.go @@ -98,16 +98,6 @@ func (s *LLMProviderTemplateService) toTemplateAPI(m *model.LLMProviderTemplate) return resp, nil } -// templateListItemResolved converts t via templateListItem and resolves its -// createdBy UUID to its raw external identity. -func (s *LLMProviderTemplateService) templateListItemResolved(t *model.LLMProviderTemplate) (api.LLMProviderTemplateListItem, error) { - item := templateListItem(t) - if err := s.identity.ResolveIdentityField(&item.CreatedBy); err != nil { - return item, err - } - return item, nil -} - func NewLLMProviderService( repo repository.LLMProviderRepository, templateRepo repository.LLMProviderTemplateRepository, @@ -307,12 +297,13 @@ func (s *LLMProviderTemplateService) List(orgUUID string, limit, offset int, lat }, } resp.List = make([]api.LLMProviderTemplateListItem, 0, len(items)) + createdByFields := make([]**string, 0, len(items)) for _, t := range items { - item, err := s.templateListItemResolved(t) - if err != nil { - return nil, err - } - resp.List = append(resp.List, item) + resp.List = append(resp.List, templateListItem(t)) + createdByFields = append(createdByFields, &resp.List[len(resp.List)-1].CreatedBy) + } + if err := s.identity.ResolveIdentityFields(createdByFields); err != nil { + return nil, err } return resp, nil } @@ -656,12 +647,13 @@ func (s *LLMProviderTemplateService) ListVersions(orgUUID, groupID string, limit }, } resp.List = make([]api.LLMProviderTemplateListItem, 0, len(items)) + createdByFields := make([]**string, 0, len(items)) for _, t := range items { - item, err := s.templateListItemResolved(t) - if err != nil { - return nil, err - } - resp.List = append(resp.List, item) + resp.List = append(resp.List, templateListItem(t)) + createdByFields = append(createdByFields, &resp.List[len(resp.List)-1].CreatedBy) + } + if err := s.identity.ResolveIdentityFields(createdByFields); err != nil { + return nil, err } return resp, nil } @@ -927,6 +919,7 @@ func (s *LLMProviderService) Create(orgUUID, createdBy string, req *api.LLMProvi Name: req.DisplayName, Description: utils.ValueOrEmpty(req.Description), CreatedBy: createdBy, + UpdatedBy: createdBy, Version: req.Version, TemplateUUID: tpl.UUID, OpenAPISpec: openapiSpec, @@ -985,6 +978,7 @@ func (s *LLMProviderService) List(orgUUID string, limit, offset int) (*api.LLMPr }, } resp.List = make([]api.LLMProviderListItem, 0, len(items)) + createdByFields := make([]**string, 0, len(items)) for _, p := range items { // Look up template handle from UUID tplHandle := "" @@ -1001,9 +995,6 @@ func (s *LLMProviderService) List(orgUUID string, limit, offset int) (*api.LLMPr name := p.Name desc := utils.StringPtrIfNotEmpty(p.Description) createdBy := utils.StringPtrIfNotEmpty(p.CreatedBy) - if err := s.identity.ResolveIdentityField(&createdBy); err != nil { - return nil, err - } version := p.Version template := utils.StringPtrIfNotEmpty(tplHandle) resp.List = append(resp.List, api.LLMProviderListItem{ @@ -1017,6 +1008,10 @@ func (s *LLMProviderService) List(orgUUID string, limit, offset int) (*api.LLMPr CreatedAt: utils.TimePtr(p.CreatedAt), UpdatedAt: utils.TimePtr(p.UpdatedAt), }) + createdByFields = append(createdByFields, &resp.List[len(resp.List)-1].CreatedBy) + } + if err := s.identity.ResolveIdentityFields(createdByFields); err != nil { + return nil, err } return resp, nil } @@ -1399,6 +1394,7 @@ func (s *LLMProxyService) Create(orgUUID, createdBy string, req *api.LLMProxy) ( Name: req.DisplayName, Description: utils.ValueOrEmpty(req.Description), CreatedBy: createdBy, + UpdatedBy: createdBy, Version: req.Version, ProviderUUID: prov.UUID, OpenAPISpec: openapiSpec, @@ -1480,14 +1476,12 @@ func (s *LLMProxyService) List(orgUUID string, projectHandle *string, limit, off }, } resp.List = make([]api.LLMProxyListItem, 0, len(items)) + createdByFields := make([]**string, 0, len(items)) for _, p := range items { id := p.ID name := p.Name desc := utils.StringPtrIfNotEmpty(p.Description) createdBy := utils.StringPtrIfNotEmpty(p.CreatedBy) - if err := s.identity.ResolveIdentityField(&createdBy); err != nil { - return nil, err - } contextValue := (*string)(nil) if p.Configuration.Context != nil { v := *p.Configuration.Context @@ -1509,6 +1503,10 @@ func (s *LLMProxyService) List(orgUUID string, projectHandle *string, limit, off CreatedAt: utils.TimePtr(p.CreatedAt), UpdatedAt: utils.TimePtr(p.UpdatedAt), }) + createdByFields = append(createdByFields, &resp.List[len(resp.List)-1].CreatedBy) + } + if err := s.identity.ResolveIdentityFields(createdByFields); err != nil { + return nil, err } return resp, nil } @@ -1545,14 +1543,12 @@ func (s *LLMProxyService) ListByProvider(orgUUID, providerID string, limit, offs }, } resp.List = make([]api.LLMProxyListItem, 0, len(items)) + createdByFields := make([]**string, 0, len(items)) for _, p := range items { id := p.ID name := p.Name desc := utils.StringPtrIfNotEmpty(p.Description) createdBy := utils.StringPtrIfNotEmpty(p.CreatedBy) - if err := s.identity.ResolveIdentityField(&createdBy); err != nil { - return nil, err - } contextValue := (*string)(nil) if p.Configuration.Context != nil { v := *p.Configuration.Context @@ -1574,6 +1570,10 @@ func (s *LLMProxyService) ListByProvider(orgUUID, providerID string, limit, offs CreatedAt: utils.TimePtr(p.CreatedAt), UpdatedAt: utils.TimePtr(p.UpdatedAt), }) + createdByFields = append(createdByFields, &resp.List[len(resp.List)-1].CreatedBy) + } + if err := s.identity.ResolveIdentityFields(createdByFields); err != nil { + return nil, err } return resp, nil } diff --git a/platform-api/internal/service/llm_apikey.go b/platform-api/internal/service/llm_apikey.go index c723392588..fa8c9b778d 100644 --- a/platform-api/internal/service/llm_apikey.go +++ b/platform-api/internal/service/llm_apikey.go @@ -104,26 +104,28 @@ func (s *LLMProviderAPIKeyService) ListLLMProviderAPIKeys( // API representation, resolving each creator UUID to its raw identity. func ownedAPIKeyItems(keys []*model.APIKey, userID string, identity *IdentityService) ([]api.APIKeyItem, error) { items := make([]api.APIKeyItem, 0, len(keys)) + createdByFields := make([]**string, 0, len(keys)) for _, k := range keys { if k.CreatedBy != userID { continue } - createdBy := utils.StringPtrIfNotEmpty(k.CreatedBy) - if err := identity.ResolveIdentityField(&createdBy); err != nil { - return nil, err - } - items = append(items, api.APIKeyItem{ + item := api.APIKeyItem{ Id: &k.Name, DisplayName: k.DisplayName, MaskedApiKey: k.MaskedAPIKey, Status: api.APIKeyItemStatus(k.Status), CreatedAt: k.CreatedAt, - CreatedBy: createdBy, + CreatedBy: utils.StringPtrIfNotEmpty(k.CreatedBy), UpdatedAt: k.UpdatedAt, ExpiresAt: k.ExpiresAt, Issuer: k.Issuer, AllowedTargets: k.AllowedTargets, - }) + } + items = append(items, item) + createdByFields = append(createdByFields, &items[len(items)-1].CreatedBy) + } + if err := identity.ResolveIdentityFields(createdByFields); err != nil { + return nil, err } return items, nil } @@ -283,6 +285,7 @@ func (s *LLMProviderAPIKeyService) CreateLLMProviderAPIKey( APIKeyHashes: apiKeyHashesJSON, Status: "active", CreatedBy: userID, + UpdatedBy: userID, ExpiresAt: req.ExpiresAt, Issuer: issuer, AllowedTargets: allowedTargets, diff --git a/platform-api/internal/service/llm_deployment.go b/platform-api/internal/service/llm_deployment.go index 7460f096d0..42daeeb223 100644 --- a/platform-api/internal/service/llm_deployment.go +++ b/platform-api/internal/service/llm_deployment.go @@ -125,7 +125,7 @@ func NewLLMProxyDeploymentService( } // DeployLLMProvider creates a new immutable deployment artifact and deploys it to a gateway -func (s *LLMProviderDeploymentService) DeployLLMProvider(providerID string, req *api.DeployRequest, orgUUID string) (*api.DeploymentResponse, error) { +func (s *LLMProviderDeploymentService) DeployLLMProvider(providerID string, req *api.DeployRequest, orgUUID, createdBy string) (*api.DeploymentResponse, error) { // Validate request if req == nil { return nil, apperror.LLMProviderDeploymentValidationFailed.New("A request body is required.") @@ -184,7 +184,7 @@ func (s *LLMProviderDeploymentService) DeployLLMProvider(providerID string, req if err != nil { return nil, err } - effectiveMetaJSON, err := s.providerRepo.EnsureGatewayAssociation(provider.UUID, gatewayID, orgUUID, deployMetaJSON, metadataProvided) + effectiveMetaJSON, err := s.providerRepo.EnsureGatewayAssociation(provider.UUID, gatewayID, orgUUID, createdBy, deployMetaJSON, metadataProvided) if err != nil { return nil, fmt.Errorf("failed to ensure gateway association: %w", err) } @@ -265,7 +265,7 @@ func (s *LLMProviderDeploymentService) DeployLLMProvider(providerID string, req if s.cfg.Deployments.TransitionalStatusEnabled { initialStatus = model.DeploymentStatusDeploying } - performedAt := time.Now().Truncate(time.Millisecond) + performedAt := time.Now().UTC().Truncate(time.Millisecond) if _, err := s.deploymentRepo.SetCurrentWithDetails( provider.UUID, orgUUID, gatewayID, deploymentID, initialStatus, string(model.DeploymentStatusDeployed), @@ -361,7 +361,7 @@ func (s *LLMProviderDeploymentService) RestoreLLMProviderDeployment(providerID, if s.cfg.Deployments.TransitionalStatusEnabled { initialStatus = model.DeploymentStatusDeploying } - performedAt := time.Now().Truncate(time.Millisecond) + performedAt := time.Now().UTC().Truncate(time.Millisecond) updatedAt, err := s.deploymentRepo.SetCurrentWithDetails( provider.UUID, orgUUID, targetDeployment.GatewayID, deploymentID, initialStatus, string(model.DeploymentStatusDeployed), @@ -453,7 +453,7 @@ func (s *LLMProviderDeploymentService) UndeployLLMProviderDeployment(providerID, if s.cfg.Deployments.TransitionalStatusEnabled { initialStatus = model.DeploymentStatusUndeploying } - performedAt := time.Now().Truncate(time.Millisecond) + performedAt := time.Now().UTC().Truncate(time.Millisecond) newUpdatedAt, err := s.deploymentRepo.SetCurrentWithDetails( provider.UUID, orgUUID, deployment.GatewayID, deploymentID, initialStatus, string(model.DeploymentStatusUndeployed), @@ -1277,7 +1277,7 @@ func unmarshalDeploymentMetadata(s string) (map[string]any, error) { } // DeployLLMProxy creates a new immutable deployment artifact and deploys it to a gateway -func (s *LLMProxyDeploymentService) DeployLLMProxy(proxyID string, req *api.DeployRequest, orgUUID string) (*api.DeploymentResponse, error) { +func (s *LLMProxyDeploymentService) DeployLLMProxy(proxyID string, req *api.DeployRequest, orgUUID, createdBy string) (*api.DeploymentResponse, error) { // Validate request if req == nil { return nil, apperror.LLMProxyDeploymentValidationFailed.New("A request body is required.") @@ -1332,7 +1332,7 @@ func (s *LLMProxyDeploymentService) DeployLLMProxy(proxyID string, req *api.Depl if err != nil { return nil, err } - effectiveMetaJSON, err := s.proxyRepo.EnsureGatewayAssociation(proxy.UUID, gatewayID, orgUUID, deployMetaJSON, metadataProvided) + effectiveMetaJSON, err := s.proxyRepo.EnsureGatewayAssociation(proxy.UUID, gatewayID, orgUUID, createdBy, deployMetaJSON, metadataProvided) if err != nil { return nil, fmt.Errorf("failed to ensure gateway association: %w", err) } @@ -1409,7 +1409,7 @@ func (s *LLMProxyDeploymentService) DeployLLMProxy(proxyID string, req *api.Depl if s.cfg.Deployments.TransitionalStatusEnabled { initialStatus = model.DeploymentStatusDeploying } - performedAt := time.Now().Truncate(time.Millisecond) + performedAt := time.Now().UTC().Truncate(time.Millisecond) if _, err := s.deploymentRepo.SetCurrentWithDetails( proxy.UUID, orgUUID, gatewayID, deploymentID, initialStatus, string(model.DeploymentStatusDeployed), @@ -1503,7 +1503,7 @@ func (s *LLMProxyDeploymentService) RestoreLLMProxyDeployment(proxyID, deploymen if s.cfg.Deployments.TransitionalStatusEnabled { initialStatus = model.DeploymentStatusDeploying } - performedAt := time.Now().Truncate(time.Millisecond) + performedAt := time.Now().UTC().Truncate(time.Millisecond) updatedAt, err := s.deploymentRepo.SetCurrentWithDetails( proxy.UUID, orgUUID, targetDeployment.GatewayID, deploymentID, initialStatus, string(model.DeploymentStatusDeployed), @@ -1595,7 +1595,7 @@ func (s *LLMProxyDeploymentService) UndeployLLMProxyDeployment(proxyID, deployme if s.cfg.Deployments.TransitionalStatusEnabled { initialStatus = model.DeploymentStatusUndeploying } - performedAt := time.Now().Truncate(time.Millisecond) + performedAt := time.Now().UTC().Truncate(time.Millisecond) newUpdatedAt, err := s.deploymentRepo.SetCurrentWithDetails( proxy.UUID, orgUUID, deployment.GatewayID, deploymentID, initialStatus, string(model.DeploymentStatusUndeployed), diff --git a/platform-api/internal/service/llm_proxy_apikey.go b/platform-api/internal/service/llm_proxy_apikey.go index b8c034e171..918413b0f3 100644 --- a/platform-api/internal/service/llm_proxy_apikey.go +++ b/platform-api/internal/service/llm_proxy_apikey.go @@ -246,6 +246,7 @@ func (s *LLMProxyAPIKeyService) CreateLLMProxyAPIKey( APIKeyHashes: apiKeyHashesJSON, Status: "active", CreatedBy: userID, + UpdatedBy: userID, ExpiresAt: req.ExpiresAt, Issuer: issuer, AllowedTargets: allowedTargets, diff --git a/platform-api/internal/service/mcp.go b/platform-api/internal/service/mcp.go index d09620150d..79b0e550c1 100644 --- a/platform-api/internal/service/mcp.go +++ b/platform-api/internal/service/mcp.go @@ -91,19 +91,6 @@ func (s *MCPProxyService) toMCPProxyAPI(m *model.MCPProxy) (*api.MCPProxy, error return resp, nil } -// mcpProxyListItemResolved converts m via mapMCPProxyModelToListItem and -// resolves its createdBy UUID to its raw external identity. -func (s *MCPProxyService) mcpProxyListItemResolved(m *model.MCPProxy) (*api.MCPProxyListItem, error) { - item := mapMCPProxyModelToListItem(m) - if item == nil { - return nil, nil - } - if err := s.identity.ResolveIdentityField(&item.CreatedBy); err != nil { - return nil, err - } - return item, nil -} - // WithSecretService injects the SecretService for secret-ref validation. func (s *MCPProxyService) WithSecretService(ss *SecretService) { s.secretService = ss @@ -254,14 +241,17 @@ func (s *MCPProxyService) List(orgUUID string, limit, offset int) (*api.MCPProxy } resp.List = make([]api.MCPProxyListItem, 0, len(proxies)) + createdByFields := make([]**string, 0, len(proxies)) for _, p := range proxies { - item, err := s.mcpProxyListItemResolved(p) - if err != nil { - return nil, err - } - if item != nil { - resp.List = append(resp.List, *item) + item := mapMCPProxyModelToListItem(p) + if item == nil { + continue } + resp.List = append(resp.List, *item) + createdByFields = append(createdByFields, &resp.List[len(resp.List)-1].CreatedBy) + } + if err := s.identity.ResolveIdentityFields(createdByFields); err != nil { + return nil, err } return resp, nil @@ -306,14 +296,17 @@ func (s *MCPProxyService) ListByProject(orgUUID, projectHandle string, limit, of } resp.List = make([]api.MCPProxyListItem, 0, len(proxies)) + createdByFields := make([]**string, 0, len(proxies)) for _, p := range proxies { - item, err := s.mcpProxyListItemResolved(p) - if err != nil { - return nil, err - } - if item != nil { - resp.List = append(resp.List, *item) + item := mapMCPProxyModelToListItem(p) + if item == nil { + continue } + resp.List = append(resp.List, *item) + createdByFields = append(createdByFields, &resp.List[len(resp.List)-1].CreatedBy) + } + if err := s.identity.ResolveIdentityFields(createdByFields); err != nil { + return nil, err } return resp, nil diff --git a/platform-api/internal/service/mcp_deployment.go b/platform-api/internal/service/mcp_deployment.go index 4e0e6871ac..91a048be21 100644 --- a/platform-api/internal/service/mcp_deployment.go +++ b/platform-api/internal/service/mcp_deployment.go @@ -234,7 +234,7 @@ func (s *MCPDeploymentService) deployMCPProxy(proxyUUID string, req *api.DeployR if err != nil { return nil, err } - effectiveMetaJSON, err := s.mcpRepo.EnsureGatewayAssociation(mcpProxy.UUID, gatewayID, orgId, deployMetaJSON, metadataProvided) + effectiveMetaJSON, err := s.mcpRepo.EnsureGatewayAssociation(mcpProxy.UUID, gatewayID, orgId, createdBy, deployMetaJSON, metadataProvided) if err != nil { return nil, fmt.Errorf("failed to ensure gateway association: %w", err) } @@ -332,7 +332,7 @@ func (s *MCPDeploymentService) deployMCPProxy(proxyUUID string, req *api.DeployR if s.cfg.Deployments.TransitionalStatusEnabled { initialStatus = model.DeploymentStatusDeploying } - performedAt := time.Now().Truncate(time.Millisecond) + performedAt := time.Now().UTC().Truncate(time.Millisecond) if _, err := s.deploymentRepo.SetCurrentWithDetails( proxyUUID, orgId, gatewayID, deploymentID, initialStatus, string(model.DeploymentStatusDeployed), @@ -461,7 +461,7 @@ func (s *MCPDeploymentService) undeployMCPProxyDeployment(proxyUUID string, depl if s.cfg.Deployments.TransitionalStatusEnabled { initialStatus = model.DeploymentStatusUndeploying } - performedAt := time.Now().Truncate(time.Millisecond) + performedAt := time.Now().UTC().Truncate(time.Millisecond) newUpdatedAt, err := s.deploymentRepo.SetCurrentWithDetails( proxyUUID, orgId, deployment.GatewayID, deployment.DeploymentID, initialStatus, string(model.DeploymentStatusUndeployed), @@ -543,7 +543,7 @@ func (s *MCPDeploymentService) restoreMCPProxyDeployment(proxyUUID string, deplo if s.cfg.Deployments.TransitionalStatusEnabled { initialStatus = model.DeploymentStatusDeploying } - performedAt := time.Now().Truncate(time.Millisecond) + performedAt := time.Now().UTC().Truncate(time.Millisecond) updatedAt, err := s.deploymentRepo.SetCurrentWithDetails( proxyUUID, orgId, targetDeployment.GatewayID, *deploymentId, initialStatus, string(model.DeploymentStatusDeployed), diff --git a/platform-api/internal/service/organization.go b/platform-api/internal/service/organization.go index 244d59ef5f..77bd164863 100644 --- a/platform-api/internal/service/organization.go +++ b/platform-api/internal/service/organization.go @@ -221,12 +221,52 @@ func (s *OrganizationService) ListOrganizations(limit, offset int) ([]api.Organi } orgs := make([]api.Organization, 0, len(orgModels)) + identityFields := make([]**string, 0, len(orgModels)*2) for _, orgModel := range orgModels { - org, convErr := s.modelToAPI(orgModel) - if convErr != nil { - return nil, 0, convErr + orgs = append(orgs, *s.modelToAPIUnresolved(orgModel)) + last := &orgs[len(orgs)-1] + identityFields = append(identityFields, &last.CreatedBy, &last.UpdatedBy) + } + if err := s.identity.ResolveIdentityFields(identityFields); err != nil { + return nil, 0, err + } + + return orgs, total, nil +} + +// ListOrganizationsForUser returns a paginated list of the organizations +// userUUID is a member of, along with the total membership count. +// +// It first best-effort heals membership for resolvedOrgUUID, so a caller +// belonging to an org that predates user_organization_mappings (e.g. a +// server-seeded org) still sees it. Heal failures are logged at Warn, not +// returned — listing proceeds with whatever membership already exists. +func (s *OrganizationService) ListOrganizationsForUser(userUUID, resolvedOrgUUID string, limit, offset int) ([]api.Organization, int, error) { + if userUUID != "" && resolvedOrgUUID != "" { + if err := s.userOrgMappingRepo.AddMembership(userUUID, resolvedOrgUUID); err != nil { + s.slogger.Warn("Failed to heal organization membership", "userUUID", userUUID, "orgUUID", resolvedOrgUUID, "error", err) } - orgs = append(orgs, *org) + } + + total, err := s.orgRepo.CountOrganizationsForUser(userUUID) + if err != nil { + return nil, 0, err + } + + orgModels, err := s.orgRepo.ListOrganizationsForUser(userUUID, limit, offset) + if err != nil { + return nil, 0, err + } + + orgs := make([]api.Organization, 0, len(orgModels)) + identityFields := make([]**string, 0, len(orgModels)*2) + for _, orgModel := range orgModels { + orgs = append(orgs, *s.modelToAPIUnresolved(orgModel)) + last := &orgs[len(orgs)-1] + identityFields = append(identityFields, &last.CreatedBy, &last.UpdatedBy) + } + if err := s.identity.ResolveIdentityFields(identityFields); err != nil { + return nil, 0, err } return orgs, total, nil @@ -275,12 +315,16 @@ func (s *OrganizationService) apiToModel(org *api.Organization, id string) *mode } } -func (s *OrganizationService) modelToAPI(orgModel *model.Organization) (*api.Organization, error) { +// modelToAPIUnresolved converts orgModel to its API representation, leaving +// createdBy/updatedBy as raw internal UUIDs. Used by list endpoints, which +// batch-resolve identity across the whole page afterward instead of +// one-by-one — see modelToAPI for the single-item equivalent that resolves +// inline. +func (s *OrganizationService) modelToAPIUnresolved(orgModel *model.Organization) *api.Organization { if orgModel == nil { - return nil, nil + return nil } - - resp := &api.Organization{ + return &api.Organization{ Id: &orgModel.Handle, DisplayName: orgModel.Name, Region: orgModel.Region, @@ -289,6 +333,13 @@ func (s *OrganizationService) modelToAPI(orgModel *model.Organization) (*api.Org CreatedAt: utils.TimePtrIfNotZero(orgModel.CreatedAt), UpdatedAt: utils.TimePtrIfNotZero(orgModel.UpdatedAt), } +} + +func (s *OrganizationService) modelToAPI(orgModel *model.Organization) (*api.Organization, error) { + resp := s.modelToAPIUnresolved(orgModel) + if resp == nil { + return nil, nil + } if err := s.identity.ResolveIdentityField(&resp.CreatedBy); err != nil { return nil, err } diff --git a/platform-api/internal/service/organization_test.go b/platform-api/internal/service/organization_test.go new file mode 100644 index 0000000000..b5fb754da9 --- /dev/null +++ b/platform-api/internal/service/organization_test.go @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package service + +import ( + "errors" + "log/slog" + "os" + "path/filepath" + "testing" + + "database/sql" + + "github.com/wso2/api-platform/platform-api/internal/database" + "github.com/wso2/api-platform/platform-api/internal/model" + "github.com/wso2/api-platform/platform-api/internal/repository" + + _ "github.com/mattn/go-sqlite3" +) + +// setupOrganizationTestEnv creates a real OrganizationService backed by SQLite. +func setupOrganizationTestEnv(t *testing.T) (*OrganizationService, *database.DB, func()) { + t.Helper() + + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "org-test.db") + // Enable foreign-key enforcement for every pooled connection via the DSN, + // not just the one connection a PRAGMA Exec would happen to run on. + sqlDB, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on") + if err != nil { + t.Fatalf("open db: %v", err) + } + db := &database.DB{DB: sqlDB} + + schemaPath := filepath.Join("..", "database", "schema.sqlite.sql") + schema, err := os.ReadFile(schemaPath) + if err != nil { + t.Fatalf("read schema: %v", err) + } + if _, err := db.Exec(string(schema)); err != nil { + t.Fatalf("apply schema: %v", err) + } + + orgRepo := repository.NewOrganizationRepo(db) + svc := &OrganizationService{ + orgRepo: orgRepo, + projectRepo: repository.NewProjectRepo(db), + auditRepo: &noopAuditRepo{}, + userOrgMappingRepo: repository.NewUserOrganizationMappingRepo(db), + identity: NewIdentityService(repository.NewUserIdentityMappingRepo(db)), + slogger: slog.Default(), + } + + cleanup := func() { sqlDB.Close() } + return svc, db, cleanup +} + +// TestOrganizationService_RegisterOrganization_RecordsMembership verifies that +// registering an organization also records the creator's membership row, so +// GET /organizations (membership-filtered) shows it to them immediately. +func TestOrganizationService_RegisterOrganization_RecordsMembership(t *testing.T) { + svc, db, cleanup := setupOrganizationTestEnv(t) + t.Cleanup(cleanup) + + actorUUID, err := svc.identity.ToInternalUUID("sub-org-creator") + if err != nil { + t.Fatalf("ToInternalUUID failed: %v", err) + } + + org, err := svc.RegisterOrganization("org-register-1", "register-org", "Register Org", "us", "", actorUUID) + if err != nil { + t.Fatalf("RegisterOrganization failed: %v", err) + } + if org == nil { + t.Fatal("RegisterOrganization returned nil organization") + } + + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM user_organization_mappings WHERE user_uuid = ? AND org_uuid = ?`, + actorUUID, "org-register-1").Scan(&count); err != nil { + t.Fatalf("failed to query membership: %v", err) + } + if count != 1 { + t.Fatalf("expected a membership row for the registering user, got count=%d", count) + } +} + +// TestOrganizationService_ListOrganizationsForUser_HealsMembership verifies +// the lazy-heal behavior: an organization predating user_organization_mappings +// (e.g. the file-based seeded org) becomes visible to a caller on their first +// list call, and a membership row is created as a side effect. +func TestOrganizationService_ListOrganizationsForUser_HealsMembership(t *testing.T) { + svc, db, cleanup := setupOrganizationTestEnv(t) + t.Cleanup(cleanup) + + // Simulate a pre-existing org with no membership rows (e.g. seeded at startup). + preexistingOrg := &model.Organization{ + ID: "org-preexisting", + Handle: "preexisting-org", + Name: "Preexisting Org", + Region: "us", + } + if err := svc.orgRepo.CreateOrganization(preexistingOrg); err != nil { + t.Fatalf("CreateOrganization failed: %v", err) + } + + userUUID, err := svc.identity.ToInternalUUID("sub-heal-user") + if err != nil { + t.Fatalf("ToInternalUUID failed: %v", err) + } + + // Before healing, the user has no visibility into the org. + orgs, total, err := svc.ListOrganizationsForUser(userUUID, "", 20, 0) + if err != nil { + t.Fatalf("ListOrganizationsForUser (pre-heal) failed: %v", err) + } + if total != 0 || len(orgs) != 0 { + t.Fatalf("expected no visible orgs before heal, got total=%d orgs=%+v", total, orgs) + } + + // Simulate the caller's token resolving to this org (as the resolver + // middleware would populate via GetOrganizationFromRequest). + orgs, total, err = svc.ListOrganizationsForUser(userUUID, "org-preexisting", 20, 0) + if err != nil { + t.Fatalf("ListOrganizationsForUser (heal) failed: %v", err) + } + if total != 1 || len(orgs) != 1 { + t.Fatalf("expected the healed org to be visible, got total=%d orgs=%+v", total, orgs) + } + + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM user_organization_mappings WHERE user_uuid = ? AND org_uuid = ?`, + userUUID, "org-preexisting").Scan(&count); err != nil { + t.Fatalf("failed to query membership: %v", err) + } + if count != 1 { + t.Fatalf("expected the heal to create a membership row, got count=%d", count) + } +} + +// TestOrganizationService_ListOrganizationsForUser_SkipsHealOnEmptyIDs +// verifies that an empty user or org UUID never attempts a heal write (which +// would otherwise fail its foreign keys) and simply lists existing membership. +func TestOrganizationService_ListOrganizationsForUser_SkipsHealOnEmptyIDs(t *testing.T) { + svc, _, cleanup := setupOrganizationTestEnv(t) + t.Cleanup(cleanup) + + userUUID, err := svc.identity.ToInternalUUID("sub-no-heal") + if err != nil { + t.Fatalf("ToInternalUUID failed: %v", err) + } + + if _, _, err := svc.ListOrganizationsForUser(userUUID, "", 20, 0); err != nil { + t.Fatalf("expected no error when resolvedOrgUUID is empty, got: %v", err) + } + if _, _, err := svc.ListOrganizationsForUser("", "some-org", 20, 0); err != nil { + t.Fatalf("expected no error when userUUID is empty, got: %v", err) + } +} + +// mockOrgRepoForErrors embeds the interface so only the methods under test +// need overriding, then forces both ListOrganizationsForUser paths to fail. +type mockOrgRepoForErrors struct { + repository.OrganizationRepository +} + +func (m *mockOrgRepoForErrors) CountOrganizationsForUser(userUUID string) (int, error) { + return 0, errors.New("count boom") +} + +func (m *mockOrgRepoForErrors) ListOrganizationsForUser(userUUID string, limit, offset int) ([]*model.Organization, error) { + return nil, errors.New("list boom") +} + +// TestOrganizationService_ListOrganizationsForUser_PropagatesRepoErrors +// verifies that repository failures surface to the caller instead of being +// swallowed (unlike the best-effort membership heal, which only logs). +func TestOrganizationService_ListOrganizationsForUser_PropagatesRepoErrors(t *testing.T) { + svc := &OrganizationService{ + orgRepo: &mockOrgRepoForErrors{}, + userOrgMappingRepo: &noopUserOrgMappingRepo{}, + identity: newTestIdentityService(), + slogger: slog.Default(), + } + + if _, _, err := svc.ListOrganizationsForUser("user-uuid", "", 20, 0); err == nil { + t.Fatal("expected CountOrganizationsForUser error to propagate") + } +} + +// noopUserOrgMappingRepo satisfies repository.UserOrganizationMappingRepository +// without a real DB, for tests that never reach AddMembership (userUUID or +// resolvedOrgUUID empty) or don't care about its outcome. +type noopUserOrgMappingRepo struct{} + +func (noopUserOrgMappingRepo) AddMembership(userUUID, orgUUID string) error { return nil } +func (noopUserOrgMappingRepo) DeleteByUser(tx *sql.Tx, userUUID string) error { return nil } +func (noopUserOrgMappingRepo) DeleteByOrg(tx *sql.Tx, orgUUID string) error { return nil } diff --git a/platform-api/internal/service/project.go b/platform-api/internal/service/project.go index 832ef04930..1a80bb3670 100644 --- a/platform-api/internal/service/project.go +++ b/platform-api/internal/service/project.go @@ -186,9 +186,10 @@ func (s *ProjectService) GetProjectsByOrganization(organizationID string, opts r return nil, 0, err } - projects := make([]api.Project, 0) + projects := make([]api.Project, 0, len(projectModels)) + createdByFields := make([]**string, 0, len(projectModels)) for _, projectModel := range projectModels { - apiProj, err := s.modelToAPI(projectModel, org.Handle) + apiProj, err := s.modelToAPIUnresolved(projectModel, org.Handle) if err != nil { return nil, 0, err } @@ -199,6 +200,10 @@ func (s *ProjectService) GetProjectsByOrganization(organizationID string, opts r // updatedBy is detail-only; omit it from list responses. apiProj.UpdatedBy = nil projects = append(projects, *apiProj) + createdByFields = append(createdByFields, &projects[len(projects)-1].CreatedBy) + } + if err := s.identity.ResolveIdentityFields(createdByFields); err != nil { + return nil, 0, err } return projects, total, nil } @@ -339,7 +344,12 @@ func (s *ProjectService) apiToModel(project *api.Project) *model.Project { } } -func (s *ProjectService) modelToAPI(projectModel *model.Project, orgHandle string) (*api.Project, error) { +// modelToAPIUnresolved converts projectModel to its API representation, +// leaving createdBy/updatedBy as raw internal UUIDs. Used by list endpoints, +// which batch-resolve identity across the whole page afterward instead of +// one-by-one — see modelToAPI for the single-item equivalent that resolves +// inline. +func (s *ProjectService) modelToAPIUnresolved(projectModel *model.Project, orgHandle string) (*api.Project, error) { if projectModel == nil { return nil, nil } @@ -350,7 +360,7 @@ func (s *ProjectService) modelToAPI(projectModel *model.Project, orgHandle strin } handle := projectModel.Handle - resp := &api.Project{ + return &api.Project{ Id: &handle, DisplayName: projectModel.Name, OrganizationId: &orgHandle, @@ -359,6 +369,13 @@ func (s *ProjectService) modelToAPI(projectModel *model.Project, orgHandle strin UpdatedBy: utils.StringPtrIfNotEmpty(projectModel.UpdatedBy), CreatedAt: utils.TimePtrIfNotZero(projectModel.CreatedAt), UpdatedAt: utils.TimePtrIfNotZero(projectModel.UpdatedAt), + }, nil +} + +func (s *ProjectService) modelToAPI(projectModel *model.Project, orgHandle string) (*api.Project, error) { + resp, err := s.modelToAPIUnresolved(projectModel, orgHandle) + if err != nil || resp == nil { + return resp, err } if err := s.identity.ResolveIdentityField(&resp.CreatedBy); err != nil { return nil, err diff --git a/platform-api/plugins/eventgateway/repository/webbroker_api.go b/platform-api/plugins/eventgateway/repository/webbroker_api.go index 38bc534c9d..9d1730962b 100644 --- a/platform-api/plugins/eventgateway/repository/webbroker_api.go +++ b/platform-api/plugins/eventgateway/repository/webbroker_api.go @@ -88,11 +88,11 @@ func (r *WebBrokerAPIRepo) Create(a *model.WebBrokerAPI) error { // Insert into webbroker_apis table query := ` INSERT INTO webbroker_apis ( - uuid, organization_uuid, handle, display_name, version, project_uuid, description, created_by, lifecycle_status, configuration, origin, data_version, created_at, updated_at + uuid, organization_uuid, handle, display_name, version, project_uuid, description, created_by, updated_by, lifecycle_status, configuration, origin, data_version, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` _, err = tx.Exec(r.db.Rebind(query), - a.UUID, a.OrganizationUUID, a.Handle, a.Name, a.Version, a.ProjectUUID, a.Description, a.CreatedBy, a.LifeCycleStatus, + a.UUID, a.OrganizationUUID, a.Handle, a.Name, a.Version, a.ProjectUUID, a.Description, a.CreatedBy, a.UpdatedBy, a.LifeCycleStatus, configurationJSON, origin, a.DataVersion, a.CreatedAt, a.UpdatedAt, ) if err != nil { diff --git a/platform-api/plugins/eventgateway/repository/websub_api.go b/platform-api/plugins/eventgateway/repository/websub_api.go index 8ead38f097..56682f2f0d 100644 --- a/platform-api/plugins/eventgateway/repository/websub_api.go +++ b/platform-api/plugins/eventgateway/repository/websub_api.go @@ -88,11 +88,11 @@ func (r *WebSubAPIRepo) Create(a *model.WebSubAPI) error { // Insert into websub_apis table query := ` INSERT INTO websub_apis ( - uuid, organization_uuid, handle, display_name, version, project_uuid, description, created_by, lifecycle_status, configuration, origin, data_version, created_at, updated_at + uuid, organization_uuid, handle, display_name, version, project_uuid, description, created_by, updated_by, lifecycle_status, configuration, origin, data_version, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` _, err = tx.Exec(r.db.Rebind(query), - a.UUID, a.OrganizationUUID, a.Handle, a.Name, a.Version, a.ProjectUUID, a.Description, a.CreatedBy, a.LifeCycleStatus, + a.UUID, a.OrganizationUUID, a.Handle, a.Name, a.Version, a.ProjectUUID, a.Description, a.CreatedBy, a.UpdatedBy, a.LifeCycleStatus, configurationJSON, origin, a.DataVersion, a.CreatedAt, a.UpdatedAt, ) if err != nil { diff --git a/platform-api/plugins/eventgateway/service/webbroker_api.go b/platform-api/plugins/eventgateway/service/webbroker_api.go index 76f65cdad3..c43d1be2f5 100644 --- a/platform-api/plugins/eventgateway/service/webbroker_api.go +++ b/platform-api/plugins/eventgateway/service/webbroker_api.go @@ -89,19 +89,6 @@ func (s *WebBrokerAPIService) toWebBrokerAPI(m *model.WebBrokerAPI) (*api.WebBro return resp, nil } -// webBrokerAPIListItemResolved converts m via mapWebBrokerAPIModelToListItem -// and resolves its createdBy UUID to its raw external identity. -func (s *WebBrokerAPIService) webBrokerAPIListItemResolved(m *model.WebBrokerAPI) (*api.WebBrokerAPIListItem, error) { - item := mapWebBrokerAPIModelToListItem(m) - if item == nil { - return nil, nil - } - if err := s.identity.ResolveIdentityField(&item.CreatedBy); err != nil { - return nil, err - } - return item, nil -} - // Create creates a new WebBroker API func (s *WebBrokerAPIService) Create(orgUUID, createdBy string, req *api.WebBrokerAPI) (*api.WebBrokerAPI, error) { if req == nil { @@ -173,6 +160,7 @@ func (s *WebBrokerAPIService) Create(orgUUID, createdBy string, req *api.WebBrok Name: req.DisplayName, Description: utils.ValueOrEmpty(req.Description), CreatedBy: createdBy, + UpdatedBy: createdBy, Version: req.Version, LifeCycleStatus: lifeCycleStatus, Configuration: model.WebBrokerAPIConfiguration{ @@ -246,14 +234,17 @@ func (s *WebBrokerAPIService) List(orgUUID, projectUUID string, limit, offset in } resp.List = make([]api.WebBrokerAPIListItem, 0, len(apis)) + createdByFields := make([]**string, 0, len(apis)) for _, a := range apis { - item, err := s.webBrokerAPIListItemResolved(a) - if err != nil { - return nil, err - } - if item != nil { - resp.List = append(resp.List, *item) + item := mapWebBrokerAPIModelToListItem(a) + if item == nil { + continue } + resp.List = append(resp.List, *item) + createdByFields = append(createdByFields, &resp.List[len(resp.List)-1].CreatedBy) + } + if err := s.identity.ResolveIdentityFields(createdByFields); err != nil { + return nil, err } return resp, nil diff --git a/platform-api/plugins/eventgateway/service/webbroker_api_deployment.go b/platform-api/plugins/eventgateway/service/webbroker_api_deployment.go index dee1471712..085dccfc93 100644 --- a/platform-api/plugins/eventgateway/service/webbroker_api_deployment.go +++ b/platform-api/plugins/eventgateway/service/webbroker_api_deployment.go @@ -251,7 +251,7 @@ func (s *WebBrokerAPIDeploymentService) deployWebBrokerAPI(apiUUID string, req * if s.cfg.Deployments.TransitionalStatusEnabled { initialStatus = model.DeploymentStatusDeploying } - performedAt := time.Now().Truncate(time.Millisecond) + performedAt := time.Now().UTC().Truncate(time.Millisecond) if _, err := s.deploymentRepo.SetCurrentWithDetails( apiUUID, orgID, gatewayID, deploymentID, initialStatus, string(model.DeploymentStatusDeployed), @@ -355,7 +355,7 @@ func (s *WebBrokerAPIDeploymentService) undeployWebBrokerAPIDeployment(apiUUID s if s.cfg.Deployments.TransitionalStatusEnabled { initialStatus = model.DeploymentStatusUndeploying } - performedAt := time.Now().Truncate(time.Millisecond) + performedAt := time.Now().UTC().Truncate(time.Millisecond) newUpdatedAt, err := s.deploymentRepo.SetCurrentWithDetails( apiUUID, orgID, deployment.GatewayID, deployment.DeploymentID, initialStatus, string(model.DeploymentStatusUndeployed), @@ -436,7 +436,7 @@ func (s *WebBrokerAPIDeploymentService) restoreWebBrokerAPIDeployment(apiUUID st if s.cfg.Deployments.TransitionalStatusEnabled { initialStatus = model.DeploymentStatusDeploying } - performedAt := time.Now().Truncate(time.Millisecond) + performedAt := time.Now().UTC().Truncate(time.Millisecond) updatedAt, err := s.deploymentRepo.SetCurrentWithDetails( apiUUID, orgID, targetDeployment.GatewayID, *deploymentId, initialStatus, string(model.DeploymentStatusDeployed), @@ -612,8 +612,6 @@ func (s *WebBrokerAPIDeploymentService) ensureAPIGatewayAssociation(apiUUID, gat ArtifactID: apiUUID, OrganizationID: orgUUID, GatewayID: gatewayID, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), } if err := s.apiRepo.CreateAPIAssociation(association); err != nil { s.slogger.Error("Failed to create API-gateway association", "apiUUID", apiUUID, "gatewayID", gatewayID, "orgUUID", orgUUID, "error", err) diff --git a/platform-api/plugins/eventgateway/service/websub_api.go b/platform-api/plugins/eventgateway/service/websub_api.go index 36791a2722..205e5dfdf9 100644 --- a/platform-api/plugins/eventgateway/service/websub_api.go +++ b/platform-api/plugins/eventgateway/service/websub_api.go @@ -89,18 +89,6 @@ func (s *WebSubAPIService) toWebSubAPI(m *model.WebSubAPI) (*api.WebSubAPI, erro return resp, nil } -// webSubAPIListItemResolved converts m via mapWebSubAPIModelToListItem and -// resolves its createdBy UUID to its raw external identity. -func (s *WebSubAPIService) webSubAPIListItemResolved(m *model.WebSubAPI) (*api.WebSubAPIListItem, error) { - item := mapWebSubAPIModelToListItem(m) - if item == nil { - return nil, nil - } - if err := s.identity.ResolveIdentityField(&item.CreatedBy); err != nil { - return nil, err - } - return item, nil -} // Create creates a new WebSub API func (s *WebSubAPIService) Create(orgUUID, createdBy string, req *api.WebSubAPI) (*api.WebSubAPI, error) { @@ -173,6 +161,7 @@ func (s *WebSubAPIService) Create(orgUUID, createdBy string, req *api.WebSubAPI) Name: req.DisplayName, Description: utils.ValueOrEmpty(req.Description), CreatedBy: createdBy, + UpdatedBy: createdBy, Version: req.Version, LifeCycleStatus: lifeCycleStatus, Origin: constants.OriginCP, @@ -245,14 +234,17 @@ func (s *WebSubAPIService) List(orgUUID, projectUUID string, limit, offset int) } resp.List = make([]api.WebSubAPIListItem, 0, len(apis)) + createdByFields := make([]**string, 0, len(apis)) for _, a := range apis { - item, err := s.webSubAPIListItemResolved(a) - if err != nil { - return nil, err - } - if item != nil { - resp.List = append(resp.List, *item) + item := mapWebSubAPIModelToListItem(a) + if item == nil { + continue } + resp.List = append(resp.List, *item) + createdByFields = append(createdByFields, &resp.List[len(resp.List)-1].CreatedBy) + } + if err := s.identity.ResolveIdentityFields(createdByFields); err != nil { + return nil, err } return resp, nil diff --git a/platform-api/plugins/eventgateway/service/websub_api_deployment.go b/platform-api/plugins/eventgateway/service/websub_api_deployment.go index 258cde42ea..b736c9400f 100644 --- a/platform-api/plugins/eventgateway/service/websub_api_deployment.go +++ b/platform-api/plugins/eventgateway/service/websub_api_deployment.go @@ -251,7 +251,7 @@ func (s *WebSubAPIDeploymentService) deployWebSubAPI(apiUUID string, req *api.De if s.cfg.Deployments.TransitionalStatusEnabled { initialStatus = model.DeploymentStatusDeploying } - performedAt := time.Now().Truncate(time.Millisecond) + performedAt := time.Now().UTC().Truncate(time.Millisecond) if _, err := s.deploymentRepo.SetCurrentWithDetails( apiUUID, orgID, gatewayID, deploymentID, initialStatus, string(model.DeploymentStatusDeployed), @@ -355,7 +355,7 @@ func (s *WebSubAPIDeploymentService) undeployWebSubAPIDeployment(apiUUID string, if s.cfg.Deployments.TransitionalStatusEnabled { initialStatus = model.DeploymentStatusUndeploying } - performedAt := time.Now().Truncate(time.Millisecond) + performedAt := time.Now().UTC().Truncate(time.Millisecond) newUpdatedAt, err := s.deploymentRepo.SetCurrentWithDetails( apiUUID, orgID, deployment.GatewayID, deployment.DeploymentID, initialStatus, string(model.DeploymentStatusUndeployed), @@ -435,7 +435,7 @@ func (s *WebSubAPIDeploymentService) restoreWebSubAPIDeployment(apiUUID string, if s.cfg.Deployments.TransitionalStatusEnabled { initialStatus = model.DeploymentStatusDeploying } - performedAt := time.Now().Truncate(time.Millisecond) + performedAt := time.Now().UTC().Truncate(time.Millisecond) updatedAt, err := s.deploymentRepo.SetCurrentWithDetails( apiUUID, orgID, targetDeployment.GatewayID, *deploymentId, initialStatus, string(model.DeploymentStatusDeployed), @@ -610,8 +610,6 @@ func (s *WebSubAPIDeploymentService) ensureAPIGatewayAssociation(apiUUID, gatewa ArtifactID: apiUUID, OrganizationID: orgUUID, GatewayID: gatewayID, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), } if err := s.apiRepo.CreateAPIAssociation(association); err != nil { s.slogger.Error("Failed to create API-gateway association", "apiUUID", apiUUID, "gatewayID", gatewayID, "orgUUID", orgUUID, "error", err) diff --git a/portals/ai-workspace/src/apis/platformApis.ts b/portals/ai-workspace/src/apis/platformApis.ts index 15cd65fbd2..af35abbb56 100644 --- a/portals/ai-workspace/src/apis/platformApis.ts +++ b/portals/ai-workspace/src/apis/platformApis.ts @@ -126,28 +126,6 @@ export async function registerOrganization( return created; } -/** - * Get the current user's organization. - * - * Endpoint: GET /organizations - * Auth: BFF session cookie; the BFF injects the bearer token. - */ -export async function getOrganization(): Promise { - const response = await fetch(platformUrl('/organizations'), { - method: 'GET', - credentials: 'include', - headers: jsonHeaders(), - }); - - if (!response.ok) { - const err = await parseApiError(response); - logger.error('getOrganization failed:', response.status, err.code, err.message, err.trackingId); - throw err; - } - - return response.json(); -} - /** * Fetch an organization by its handle. * Returns null when the org is not yet registered (404). diff --git a/portals/ai-workspace/src/contexts/ChoreoUserContext.tsx b/portals/ai-workspace/src/contexts/ChoreoUserContext.tsx index 6cc9ba23cd..c7dca463ca 100644 --- a/portals/ai-workspace/src/contexts/ChoreoUserContext.tsx +++ b/portals/ai-workspace/src/contexts/ChoreoUserContext.tsx @@ -61,8 +61,7 @@ const ChoreoUserContext = createContext(null); // ── Helpers ─────────────────────────────────────────────────────────────────── /** - * Fetch the current user's organization from the Platform API. - * Returns a single org wrapped in an array (Platform API returns one org per JWT). + * Fetch the organizations the current user is a member of from the Platform API. * * Routed same-origin through the BFF: the request carries the HttpOnly * `_ai_workspace_session` cookie (credentials: 'include') and the BFF injects the bearer @@ -74,33 +73,56 @@ async function fetchPlatformOrganization(): Promise { 'Content-Type': 'application/json', }; - const res = await fetch(`${PLATFORM_API_BASE_URL}/organizations`, { - credentials: 'include', - headers, + // The API's `id` field is the organization's handle (a URL-safe slug), not + // a UUID — there is no separate handle field, and no internal UUID is ever + // exposed to clients. + const mapOrg = (platformOrg: { id: string; displayName: string; region?: string }): Organization => ({ + id: platformOrg.id, + uuid: platformOrg.id, + handle: platformOrg.id, + name: platformOrg.displayName, + region: platformOrg.region, + owner: { id: 0, idpId: '' }, }); - if (!res.ok) { - if (res.status === 404) { - logger.warn('[ChoreoUserContext] No organization found — register one at /register-org'); - return []; + // GET /organizations is paginated ({ count, list, pagination }); a caller who + // belongs to more organizations than one page holds spans several pages, so + // follow pagination.total and merge every page rather than keeping only the + // first. + const orgs: Organization[] = []; + let offset = 0; + for (;;) { + const res = await fetch(`${PLATFORM_API_BASE_URL}/organizations?offset=${offset}`, { + credentials: 'include', + headers, + }); + + if (!res.ok) { + if (res.status === 404) { + logger.warn('[ChoreoUserContext] No organization found — register one at /register-org'); + return []; + } + const body = await res.json().catch(() => ({})); + throw new Error(body?.message ?? `GET /organizations failed: HTTP ${res.status}`); } - const body = await res.json().catch(() => ({})); - throw new Error(body?.message ?? `GET /organizations failed: HTTP ${res.status}`); - } - // Platform API returns a single Organization object (not an array) - const platformOrg = await res.json(); - logger.info('[ChoreoUserContext] Loaded organization:', platformOrg.handle); + const body = await res.json(); + const page: Array<{ id: string; displayName: string; region?: string }> = Array.isArray(body?.list) + ? body.list + : []; + orgs.push(...page.map(mapOrg)); + + const total = typeof body?.pagination?.total === 'number' ? body.pagination.total : orgs.length; + // Stop once every organization is collected, or defensively if a page comes + // back empty (so a stale/growing total can never spin this forever). + if (orgs.length >= total || page.length === 0) { + break; + } + offset = orgs.length; + } - const org: Organization = { - id: platformOrg.id, // UUID string - uuid: platformOrg.id, // alias kept for backward compat - handle: platformOrg.handle, - name: platformOrg.displayName, - region: platformOrg.region, - owner: { id: 0, idpId: '' }, - }; - return [org]; + logger.info('[ChoreoUserContext] Loaded organizations:', orgs.map((o) => o.id)); + return orgs; } // ── Provider ────────────────────────────────────────────────────────────────── diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/externalServers/ExternalServersOverview.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/externalServers/ExternalServersOverview.tsx index 18b93442d2..1e055a5b15 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/externalServers/ExternalServersOverview.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/externalServers/ExternalServersOverview.tsx @@ -454,7 +454,7 @@ export default function ExternalServersOverview(): JSX.Element { }; }); - const { createdAt, updatedAt, ...updatePayload } = server; + const { createdAt, createdBy, updatedAt, updatedBy, ...updatePayload } = server; try { setIsSavingChanges(true); @@ -701,6 +701,15 @@ export default function ExternalServersOverview(): JSX.Element { {formatRelativeTime(server.updatedAt)} + {server.createdBy && ( + + + + )} diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/EditProviderTemplate.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/EditProviderTemplate.tsx index 1c093aff6a..bbbe8ff0e1 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/EditProviderTemplate.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/EditProviderTemplate.tsx @@ -72,7 +72,7 @@ function EditProviderTemplateForm({ template }: { template: ProviderTemplate }) if (event) event.preventDefault(); if (!templateId || !isFormValid || isSubmitting) return; - const { createdAt, createdBy, updatedAt, ...rest } = template; + const { createdAt, createdBy, updatedAt, updatedBy, ...rest } = template; const payload: UpdateProviderTemplateRequest = { ...rest, id: templateId, diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplateOverview.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplateOverview.tsx index 70e182dfa1..76be6aa087 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplateOverview.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplateOverview.tsx @@ -813,6 +813,15 @@ export default function ProviderTemplateOverview() { {lastUpdated ? formatRelativeTime(lastUpdated) : '—'} + {template.createdBy && ( + + + + )} diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/EditLLMProxy.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/EditLLMProxy.tsx index abb869a45a..68c91fce5e 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/EditLLMProxy.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/EditLLMProxy.tsx @@ -98,6 +98,7 @@ function EditLLMProxyForm() { delete (fullPayload as any).createdAt; delete (fullPayload as any).createdBy; delete (fullPayload as any).updatedAt; + delete (fullPayload as any).updatedBy; await updateProxy(fullPayload); diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyOverview.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyOverview.tsx index 09d88c6e5f..683a47b140 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyOverview.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyOverview.tsx @@ -133,6 +133,7 @@ const buildProxyUpdatePayload = (value: LLMProxy): UpdateProxyRequest => { delete updatePayload.vhost; } delete (updatePayload as Record).createdBy; + delete (updatePayload as Record).updatedBy; return updatePayload; }; @@ -424,6 +425,19 @@ function ProxyOverviewContent() { {formatRelativeTime(proxy.updatedAt)} + {proxy.createdBy && ( + + + + + + {proxy.createdBy} + + + )} diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/EditServiceProvider.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/EditServiceProvider.tsx index 7f8f33c32d..9e304bd473 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/EditServiceProvider.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/EditServiceProvider.tsx @@ -94,6 +94,7 @@ function EditServiceProviderForm() { delete (fullPayload as any).createdAt; delete (fullPayload as any).createdBy; delete (fullPayload as any).updatedAt; + delete (fullPayload as any).updatedBy; delete (fullPayload as any).lastUpdated; await updateProvider(fullPayload); diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderConnectionTab.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderConnectionTab.tsx index 32811ca441..9e70d26bb1 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderConnectionTab.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderConnectionTab.tsx @@ -121,6 +121,7 @@ export default function ServiceProviderConnectionTab() { createdAt, createdBy, updatedAt, + updatedBy, lastUpdated, ...updatePayload } = provider; @@ -159,6 +160,7 @@ export default function ServiceProviderConnectionTab() { createdAt, createdBy, updatedAt, + updatedBy, lastUpdated, ...updatePayload } = provider; @@ -198,6 +200,7 @@ export default function ServiceProviderConnectionTab() { createdAt, createdBy, updatedAt, + updatedBy, lastUpdated, ...updatePayload } = provider; @@ -245,6 +248,7 @@ export default function ServiceProviderConnectionTab() { createdAt, createdBy, updatedAt, + updatedBy, lastUpdated, ...updatePayload } = provider; diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderGuardrailsTab.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderGuardrailsTab.tsx index 8dd1a4d6c9..30a50ef95f 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderGuardrailsTab.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderGuardrailsTab.tsx @@ -504,6 +504,7 @@ export default function ServiceProviderGuardrailsTab() { createdAt, createdBy, updatedAt, + updatedBy, lastUpdated, ...updatePayload } = provider; @@ -631,6 +632,7 @@ export default function ServiceProviderGuardrailsTab() { createdAt, createdBy, updatedAt, + updatedBy, lastUpdated, ...updatePayload } = provider; diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderModelsTab.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderModelsTab.tsx index a295b2c832..1a7b1a83d0 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderModelsTab.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderModelsTab.tsx @@ -235,6 +235,7 @@ export default function ServiceProviderModelsTab() { createdAt, createdBy, updatedAt, + updatedBy, lastUpdated, ...updatePayload } = provider; diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverview.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverview.tsx index 5f7d0d976d..df46bc8ddc 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverview.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverview.tsx @@ -227,7 +227,7 @@ const UNSAVED_CHANGES_MESSAGE = const stripReadOnlyProviderFields = ( value: LLMProvider ): UpdateLLMProviderRequest => { - const { status, createdAt, createdBy, updatedAt, lastUpdated, ...payload } = + const { status, createdAt, createdBy, updatedAt, updatedBy, lastUpdated, ...payload } = value; return payload; }; @@ -1205,6 +1205,15 @@ function ServiceProviderOverviewContent() { {lastUpdated ? formatRelativeTime(lastUpdated) : '—'} + {provider.createdBy && ( + + + + )} @@ -1482,6 +1491,15 @@ function ServiceProviderOverviewContent() { {lastUpdated ? formatRelativeTime(lastUpdated) : '—'} + {provider.createdBy && ( + + + + )} diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderRateLimitingTab.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderRateLimitingTab.tsx index 022e775dc3..1f1ca6b130 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderRateLimitingTab.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderRateLimitingTab.tsx @@ -865,6 +865,7 @@ export default function ServiceProviderRateLimitingTab({ createdAt, createdBy, updatedAt, + updatedBy, lastUpdated, ...updatePayload } = provider; diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderResourcesTab.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderResourcesTab.tsx index f8d3e6c68a..7d4487f5e1 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderResourcesTab.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderResourcesTab.tsx @@ -250,6 +250,7 @@ export default function ServiceProviderResourcesTab() { createdAt, createdBy, updatedAt, + updatedBy, lastUpdated, ...updatePayload } = provider; diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderSecurityTab.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderSecurityTab.tsx index 2c8bfc3233..cd79ab707f 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderSecurityTab.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderSecurityTab.tsx @@ -68,6 +68,7 @@ export default function ServiceProviderSecurityTab() { createdAt, createdBy, updatedAt, + updatedBy, lastUpdated, ...updatePayload } = provider; diff --git a/portals/ai-workspace/src/utils/types.ts b/portals/ai-workspace/src/utils/types.ts index 2c5fbcae04..4f4ff47eef 100644 --- a/portals/ai-workspace/src/utils/types.ts +++ b/portals/ai-workspace/src/utils/types.ts @@ -388,6 +388,7 @@ export interface LLMProvider { createdAt?: string; createdBy?: string; updatedAt?: string; + updatedBy?: string; lastUpdated?: string; } @@ -418,6 +419,7 @@ type LLMProviderReadOnlyFields = | 'createdAt' | 'createdBy' | 'updatedAt' + | 'updatedBy' | 'lastUpdated'; /** @@ -493,6 +495,7 @@ export interface ProviderTemplate { createdBy?: string; createdAt?: string; updatedAt?: string; + updatedBy?: string; /** Flat logo URL, present on list responses (LLMProviderTemplateListItem). */ logoUrl?: string; } @@ -608,7 +611,9 @@ export interface Proxy { security?: ProxySecurityConfig; readOnly?: boolean; createdAt?: string; + createdBy?: string; updatedAt?: string; + updatedBy?: string; } // LLM Provider Deployment Types // ============================================================================ @@ -653,7 +658,7 @@ export interface CreateProxyRequest { } /** Read-only fields from API (excluded from update requests) */ -type ProxyReadOnlyFields = 'createdAt' | 'updatedAt'; +type ProxyReadOnlyFields = 'createdAt' | 'createdBy' | 'updatedAt' | 'updatedBy'; /** * Update Proxy request - all fields optional, read-only excluded @@ -787,7 +792,9 @@ export interface MCPServer { capabilities?: MCPServerCapabilities; readOnly?: boolean; createdAt?: string; + createdBy?: string; updatedAt?: string; + updatedBy?: string; } /** @@ -809,7 +816,7 @@ export interface CreateMCPServerRequest { } /** Read-only fields from API (excluded from update requests) */ -type MCPServerReadOnlyFields = 'id' | 'createdAt' | 'updatedAt'; +type MCPServerReadOnlyFields = 'id' | 'createdAt' | 'createdBy' | 'updatedAt' | 'updatedBy'; /** * Update MCP Server request - all fields optional, read-only excluded From c667b7bbd5f11882d665007b62ec9cacd1efa15c Mon Sep 17 00:00:00 2001 From: Ashera Silva Date: Wed, 15 Jul 2026 20:55:55 +0530 Subject: [PATCH 2/3] Ensure SQLite driver reads and returns timestamps in UTC --- platform-api/internal/database/connection.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/platform-api/internal/database/connection.go b/platform-api/internal/database/connection.go index ff6faad24e..905cb45742 100644 --- a/platform-api/internal/database/connection.go +++ b/platform-api/internal/database/connection.go @@ -86,8 +86,16 @@ func NewConnection(cfg *config.Database, slogger *slog.Logger) (*DB, error) { return nil, fmt.Errorf("failed to create database directory: %w", err) } - // Open SQLite connection to the api_platform.db file - db, err = sql.Open(DriverSQLite, cfg.Path) + // Open SQLite connection to the api_platform.db file. + // _loc=UTC forces the driver to interpret and return timestamps in UTC, + // matching the time.Now().UTC() values written throughout the codebase. + dsn := cfg.Path + if strings.Contains(dsn, "?") { + dsn += "&_loc=UTC" + } else { + dsn += "?_loc=UTC" + } + db, err = sql.Open(DriverSQLite, dsn) if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) } From 9cc2fa90e4c40e530f1af88a0668e0af502caba9 Mon Sep 17 00:00:00 2001 From: Renuka Fernando Date: Thu, 16 Jul 2026 00:00:25 +0530 Subject: [PATCH 3/3] fix(platform-api): normalize caller-provided timestamps to UTC in deployment repo - In CreateWithLimitEnforcement, add else branch to call .UTC() on caller-provided CreatedAt values; the existing fix only covered the zero-value (default to now) path, leaving import-flow timestamps potentially in local time and breaking the eviction ORDER BY created_at ASC ordering on non-UTC hosts. - In SetCurrentWithDetails, call .UTC() on the dereferenced performedAt pointer so the concurrency token is always UTC-normalized, matching the updatedAt column already written as time.Now().UTC(). --- platform-api/internal/repository/deployment.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/platform-api/internal/repository/deployment.go b/platform-api/internal/repository/deployment.go index 67c36e46e8..65feb41fe7 100644 --- a/platform-api/internal/repository/deployment.go +++ b/platform-api/internal/repository/deployment.go @@ -68,6 +68,8 @@ func (r *DeploymentRepo) CreateWithLimitEnforcement(deployment *model.Deployment // deployment time, which drives the last-in-wins watermark); default to now otherwise. if deployment.CreatedAt.IsZero() { deployment.CreatedAt = time.Now().UTC() + } else { + deployment.CreatedAt = deployment.CreatedAt.UTC() } // Status must be provided and should be DEPLOYED for new deployments @@ -344,7 +346,7 @@ func (r *DeploymentRepo) SetCurrentWithDetails(artifactUUID, orgUUID, gatewayID, updatedAt := time.Now().UTC() var pat time.Time if performedAt != nil { - pat = *performedAt + pat = performedAt.UTC() } else { pat = updatedAt }